social-autoposter 1.3.10 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/server.js +1308 -135
- package/package.json +1 -1
- 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/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 +119 -18
- package/scripts/fetch_twitter_t1.py +41 -29
- package/scripts/github_tools.py +139 -10
- package/scripts/ig_post_type_picker.py +113 -37
- package/scripts/insert_post029.py +86 -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/install_lane_digest.py +5 -1
- package/scripts/install_lane_monitor.py +1 -1
- 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 +136 -35
- package/scripts/precompute_dashboard_stats.py +13 -1
- package/scripts/project_stats_json.py +23 -0
- package/scripts/reconcile_twitter_search_topic.py +125 -0
- package/scripts/reddit_browser.py +49 -2
- package/scripts/reddit_tools.py +12 -0
- package/scripts/regenerate_ig_plists.py +262 -0
- package/scripts/scan_twitter_mentions_browser.py +150 -63
- package/scripts/scan_twitter_thread_followups.py +78 -52
- package/scripts/score_twitter_candidates.py +102 -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 +223 -14
- package/scripts/top_twitter_queries.py +30 -124
- package/scripts/twitter_batch_phase.py +26 -44
- package/scripts/twitter_browser.py +31 -20
- package/scripts/twitter_cycle_helper.py +268 -0
- package/scripts/twitter_gen_links.py +35 -2
- package/scripts/twitter_post_plan.py +67 -40
- package/scripts/twitter_supply_signal.py +8 -53
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/update_stats.py +218 -24
- package/skill/dm-outreach-linkedin.sh +1 -1
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +23 -49
- 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 +37 -63
- package/skill/lib/twitter-backend.sh +14 -2
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/run-github.sh +13 -1
- package/skill/run-instagram-daily.sh +24 -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 +351 -221
- package/skill/run-twitter-threads.sh +19 -27
- package/skill/stats.sh +38 -3
package/bin/server.js
CHANGED
|
@@ -477,7 +477,11 @@ const RUN_MONITOR_PATH = path.join(LOG_DIR, 'run_monitor.log');
|
|
|
477
477
|
// queries+candidates+above_floor only. Each sub-key is omitted when zero, so
|
|
478
478
|
// `discover=` itself is absent on lines from pipelines that don't emit it.
|
|
479
479
|
// Old log lines without the segment still parse cleanly via the optional `?`.
|
|
480
|
-
|
|
480
|
+
// 2026-05-18 stats-pill relabel: three new optional groups (scanned,
|
|
481
|
+
// changed, views_refreshed) tail the unavailable/not_found block so old log
|
|
482
|
+
// lines still parse via the existing positional regex. Each is independently
|
|
483
|
+
// optional so a partial roll-out (just scanned, just changed) also parses.
|
|
484
|
+
const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*posted=(\d+)\s+skipped=(\d+)\s+failed=(\d+)(?:\s+replies_refreshed=(\d+))?(?:\s+checked=(\d+)\s+updated=(\d+)\s+removed=(\d+))?(?:\s+unavailable=(\d+))?(?:\s+not_found=(\d+))?(?:\s+scanned=(\d+))?(?:\s+changed=(\d+))?(?:\s+views_refreshed=(\d+))?(?:\s+salvaged=(\d+))?(?:\s+discover=([^\s|]+))?(?:\s+scan=([^\s|]+))?\s+cost=\$([\d.]+)\s+elapsed=(\d+)s(?:\s+failure_reasons=([^\s|]+))?(?:\s+skip_reasons=([^\s|]+))?/;
|
|
481
485
|
|
|
482
486
|
// posts.platform is lowercase; UI labels are capitalized.
|
|
483
487
|
const PLATFORM_LABELS = {
|
|
@@ -586,7 +590,7 @@ function parseRunMonitorLog(maxLines) {
|
|
|
586
590
|
for (const line of tail) {
|
|
587
591
|
const m = line.match(RUN_LINE_RE);
|
|
588
592
|
if (!m) continue;
|
|
589
|
-
const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
|
|
593
|
+
const [, ts, script, posted, skipped, failed, repliesRefreshed, checked, updated, removed, unavailable, notFound, scannedRaw, changedRaw, viewsRefreshedRaw, salvaged, discoverStr, scanStr, cost, elapsed, failureReasonsStr, skipReasonsStr] = m;
|
|
590
594
|
if (JOB_HISTORY_HIDDEN_SCRIPTS.has(script)) continue;
|
|
591
595
|
// log_run.py writes naive local-wallclock time (strftime without tz), so
|
|
592
596
|
// `new Date(ts)` in node interprets it as local on the server. That is
|
|
@@ -670,6 +674,13 @@ function parseRunMonitorLog(maxLines) {
|
|
|
670
674
|
removed: removed ? parseInt(removed, 10) : 0,
|
|
671
675
|
unavailable: unavailable ? parseInt(unavailable, 10) : 0,
|
|
672
676
|
not_found: notFound ? parseInt(notFound, 10) : 0,
|
|
677
|
+
// 2026-05-18 stats-pill relabel: three new fields surface the split
|
|
678
|
+
// between Step 1 view-scrape leg and Step 2 metric-changed leg.
|
|
679
|
+
// Absent on pre-relabel log lines (defaults to 0); renderResultPills
|
|
680
|
+
// falls back to `checked`/`updated` when these are 0.
|
|
681
|
+
scanned: scannedRaw ? parseInt(scannedRaw, 10) : 0,
|
|
682
|
+
changed: changedRaw ? parseInt(changedRaw, 10) : 0,
|
|
683
|
+
views_refreshed: viewsRefreshedRaw ? parseInt(viewsRefreshedRaw, 10) : 0,
|
|
673
684
|
salvaged: salvaged ? parseInt(salvaged, 10) : 0,
|
|
674
685
|
discover, // {} when no `discover=` segment was present on the line
|
|
675
686
|
scan, // {} when no `scan=` segment was present on the line
|
|
@@ -1130,7 +1141,8 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1130
1141
|
// otherwise undercount the per-run queue snapshot at older runs.
|
|
1131
1142
|
const candidateRows = await pq(
|
|
1132
1143
|
"SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
|
|
1133
|
-
" (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id " +
|
|
1144
|
+
" (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
|
|
1145
|
+
" matched_project " +
|
|
1134
1146
|
"FROM twitter_candidates " +
|
|
1135
1147
|
"WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
|
|
1136
1148
|
[since]
|
|
@@ -1194,6 +1206,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1194
1206
|
exitMs,
|
|
1195
1207
|
status: r.status,
|
|
1196
1208
|
batch_id: r.batch_id || '',
|
|
1209
|
+
matched_project: r.matched_project || '',
|
|
1197
1210
|
};
|
|
1198
1211
|
});
|
|
1199
1212
|
|
|
@@ -1235,9 +1248,25 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1235
1248
|
}
|
|
1236
1249
|
let candidatesPassed = 0;
|
|
1237
1250
|
let salvagePosted = 0;
|
|
1251
|
+
// Project labels this cycle actually worked on, surfaced at the end of the
|
|
1252
|
+
// pill row (mirrors enrichPostCommentsRedditRuns). Source is the
|
|
1253
|
+
// twitter_candidates.matched_project values for rows tied to this run's
|
|
1254
|
+
// own batch_id — i.e. tweets Phase 1 scraped + scored on behalf of these
|
|
1255
|
+
// projects. Insertion-order Set preserves first-seen order while deduping.
|
|
1256
|
+
const projectsSeen = new Set();
|
|
1257
|
+
const projectsList = [];
|
|
1258
|
+
const recordProject = (raw) => {
|
|
1259
|
+
if (!raw) return;
|
|
1260
|
+
const proj = raw.trim();
|
|
1261
|
+
if (proj && !projectsSeen.has(proj)) {
|
|
1262
|
+
projectsSeen.add(proj);
|
|
1263
|
+
projectsList.push(proj);
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1238
1266
|
for (const c of candNorm) {
|
|
1239
1267
|
if (!ownBatchId || c.batch_id !== ownBatchId) continue;
|
|
1240
1268
|
candidatesPassed++;
|
|
1269
|
+
recordProject(c.matched_project);
|
|
1241
1270
|
if (c.status === 'posted') {
|
|
1242
1271
|
posted++;
|
|
1243
1272
|
// Salvage signature: candidate's discovered_at predates this cycle's
|
|
@@ -1270,6 +1299,19 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1270
1299
|
const body = fs.readFileSync(path.join(LOG_DIR, chosenLog), 'utf8');
|
|
1271
1300
|
const m = body.match(phase0SalvageRe);
|
|
1272
1301
|
if (m) salvageAttempted = parseInt(m[1], 10);
|
|
1302
|
+
// Fallback for scan-only cycles where 0 candidates upserted (so
|
|
1303
|
+
// matched_project never landed in twitter_candidates for this batch):
|
|
1304
|
+
// pull project names from the cycle log's "Selected projects:" header.
|
|
1305
|
+
// This surfaces the projects the cycle SCANNED for, mirroring Reddit's
|
|
1306
|
+
// behaviour of capturing project labels even when nothing posted.
|
|
1307
|
+
if (!projectsList.length) {
|
|
1308
|
+
const selM = body.match(/Selected projects:\s*([^\n]+)/);
|
|
1309
|
+
if (selM) {
|
|
1310
|
+
for (const tok of selM[1].split(',')) {
|
|
1311
|
+
recordProject(tok);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1273
1315
|
} catch { /* empty */ }
|
|
1274
1316
|
}
|
|
1275
1317
|
// Per-run queue delta. ADD = candidates whose discovered_at fell in this
|
|
@@ -1373,6 +1415,12 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1373
1415
|
salvage_attempted: salvageAttempted,
|
|
1374
1416
|
salvage_posted: salvagePosted,
|
|
1375
1417
|
own_batch_id: ownBatchId,
|
|
1418
|
+
// Project(s) this cycle actually worked on (Phase 1 scraped + scored
|
|
1419
|
+
// candidates for), parsed from twitter_candidates.matched_project for
|
|
1420
|
+
// rows tied to this run's batch_id. Surfaces at the end of the dashboard
|
|
1421
|
+
// pill row so the operator can see at a glance which projects consumed
|
|
1422
|
+
// the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
|
|
1423
|
+
projects_worked: projectsList,
|
|
1376
1424
|
cost_usd: prior.cost_usd || 0,
|
|
1377
1425
|
failed: prior.failed || 0,
|
|
1378
1426
|
failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
|
|
@@ -2224,7 +2272,7 @@ async function enrichSeoRuns(runs) {
|
|
|
2224
2272
|
}
|
|
2225
2273
|
if (sessionIds.length) {
|
|
2226
2274
|
const rows = await pq(
|
|
2227
|
-
'SELECT session_id, total_cost_usd, orchestrator_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
|
|
2275
|
+
'SELECT session_id, total_cost_usd, orchestrator_cost_usd, subagent_cost_usd FROM claude_sessions WHERE session_id = ANY($1::uuid[])',
|
|
2228
2276
|
[sessionIds]
|
|
2229
2277
|
);
|
|
2230
2278
|
if (rows && rows.length) {
|
|
@@ -2234,6 +2282,9 @@ async function enrichSeoRuns(runs) {
|
|
|
2234
2282
|
orchestrator: r.orchestrator_cost_usd != null
|
|
2235
2283
|
? Number(r.orchestrator_cost_usd)
|
|
2236
2284
|
: null,
|
|
2285
|
+
subagent: r.subagent_cost_usd != null
|
|
2286
|
+
? Number(r.subagent_cost_usd)
|
|
2287
|
+
: null,
|
|
2237
2288
|
}])
|
|
2238
2289
|
);
|
|
2239
2290
|
for (const run of seoRuns) {
|
|
@@ -2246,16 +2297,20 @@ async function enrichSeoRuns(runs) {
|
|
|
2246
2297
|
// sessions, locked callers that don't pass --orchestrator-cost-usd),
|
|
2247
2298
|
// keep whatever streamRes value _collectSeoDetails parsed from the
|
|
2248
2299
|
// .log file.
|
|
2300
|
+
// SDK-only: d.cost_usd is orchestrator_cost_usd alone. Estimate
|
|
2301
|
+
// and subagent are kept on the row for diagnostic tooltips but
|
|
2302
|
+
// not folded into the displayed total.
|
|
2249
2303
|
if (Number.isFinite(row.orchestrator)) {
|
|
2250
2304
|
d.cost_usd = row.orchestrator;
|
|
2251
2305
|
d.cost_usd_orchestrator = row.orchestrator;
|
|
2252
|
-
} else
|
|
2253
|
-
d.
|
|
2306
|
+
} else {
|
|
2307
|
+
d.cost_usd = null;
|
|
2308
|
+
d.cost_usd_orchestrator = null;
|
|
2254
2309
|
}
|
|
2255
|
-
// Manual estimate alongside (always populated by log_claude_session.py).
|
|
2256
2310
|
if (Number.isFinite(row.estimated)) {
|
|
2257
2311
|
d.cost_usd_estimated = row.estimated;
|
|
2258
2312
|
}
|
|
2313
|
+
d.cost_usd_subagent = Number.isFinite(row.subagent) ? row.subagent : 0;
|
|
2259
2314
|
}
|
|
2260
2315
|
}
|
|
2261
2316
|
}
|
|
@@ -2284,6 +2339,229 @@ async function enrichSeoRuns(runs) {
|
|
|
2284
2339
|
}
|
|
2285
2340
|
}
|
|
2286
2341
|
|
|
2342
|
+
// Maps a Job History row's `script` (log_run.py canonical name) to the
|
|
2343
|
+
// `claude_sessions.script` values its wrapper actually spawns. Without this
|
|
2344
|
+
// constraint a 40-min post_reddit run's window catches every concurrent
|
|
2345
|
+
// pipeline's sessions (engage-dm-replies, seo_generate_page, etc.) and the
|
|
2346
|
+
// "cost" cell sums everything that ran in parallel — meaningless. Lookup is
|
|
2347
|
+
// keyed on the underscore form (post_reddit, dm_replies_reddit) that survives
|
|
2348
|
+
// classifyScript's normalization. Add new entries here when a new wrapper
|
|
2349
|
+
// pipeline ships.
|
|
2350
|
+
const _PHASE_FAMILY = {
|
|
2351
|
+
// Reddit
|
|
2352
|
+
post_reddit: ['post_reddit'],
|
|
2353
|
+
engage_reddit: ['engage_reddit'],
|
|
2354
|
+
thread_reddit: ['run-reddit-threads'],
|
|
2355
|
+
dm_outreach_reddit: ['dm-outreach-reddit'],
|
|
2356
|
+
dm_replies_reddit: ['engage-dm-replies'],
|
|
2357
|
+
link_edit_reddit: ['link-edit-reddit'],
|
|
2358
|
+
// Twitter
|
|
2359
|
+
post_twitter: ['run-twitter-cycle-scan', 'run-twitter-cycle-prep'],
|
|
2360
|
+
engage_twitter: ['engage-twitter-phaseB'],
|
|
2361
|
+
thread_twitter: ['run-twitter-threads'],
|
|
2362
|
+
dm_outreach_twitter: ['dm-outreach-twitter'],
|
|
2363
|
+
dm_replies_twitter: ['engage-dm-replies'],
|
|
2364
|
+
link_edit_twitter: ['link-edit-twitter'],
|
|
2365
|
+
// LinkedIn (run-linkedin bare = pre-phase-split legacy tag, kept for old rows)
|
|
2366
|
+
post_linkedin: ['run-linkedin-phaseA', 'run-linkedin-phaseB', 'run-linkedin'],
|
|
2367
|
+
engage_linkedin: ['engage-linkedin-phaseA', 'engage-linkedin-phaseB'],
|
|
2368
|
+
dm_replies_linkedin: ['engage-dm-replies'],
|
|
2369
|
+
dm_outreach_linkedin: ['dm-outreach-linkedin'],
|
|
2370
|
+
link_edit_linkedin: ['link-edit-linkedin'],
|
|
2371
|
+
// GitHub
|
|
2372
|
+
post_github: ['post_github'],
|
|
2373
|
+
engage_github: ['engage_github', 'github-engage', 'run-github-cycle'],
|
|
2374
|
+
link_edit_github: ['link-edit-github'],
|
|
2375
|
+
// Moltbook
|
|
2376
|
+
post_moltbook: ['run-moltbook-cycle'],
|
|
2377
|
+
// SEO
|
|
2378
|
+
gsc_seo: ['seo_generate_page', 'seo_generate_page_retry'],
|
|
2379
|
+
serp_seo: ['seo_generate_page', 'seo_generate_page_retry'],
|
|
2380
|
+
seo_improve: ['seo_improve_page'],
|
|
2381
|
+
seo_weekly_roundup: ['seo_generate_page'],
|
|
2382
|
+
seo_top_pages: ['seo_generate_page'],
|
|
2383
|
+
seo_top_posts: ['seo_generate_page'],
|
|
2384
|
+
};
|
|
2385
|
+
|
|
2386
|
+
function _phaseFamilyFor(runScript) {
|
|
2387
|
+
if (!runScript) return null;
|
|
2388
|
+
const norm = String(runScript).replace(/-/g, '_').toLowerCase();
|
|
2389
|
+
const aliased = SCRIPT_ALIASES[norm] || norm;
|
|
2390
|
+
const family = _PHASE_FAMILY[aliased];
|
|
2391
|
+
return family && family.length ? new Set(family) : null;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Per-phase Claude cost breakdown for Job History rows. For each completed
|
|
2395
|
+
// run, queries `claude_sessions` rows whose `script` belongs to the run's
|
|
2396
|
+
// known phase family (see _PHASE_FAMILY) AND whose `started_at` falls inside
|
|
2397
|
+
// [run.started_at - slack, run.finished_at + slack]. Groups results by
|
|
2398
|
+
// session script (the phase) and attaches:
|
|
2399
|
+
// run.result.cost_breakdown = {
|
|
2400
|
+
// total, orchestrator, subagent, estimated,
|
|
2401
|
+
// phases: [{phase, sessions, total, orch, sub, est}, ...] // desc by total
|
|
2402
|
+
// }
|
|
2403
|
+
// run.result.cost_usd = total (overrides the shell-log value when
|
|
2404
|
+
// we found ≥1 matching session — the wrapper
|
|
2405
|
+
// log line often misses sub-phase cost,
|
|
2406
|
+
// e.g. engage_twitter logs $0 but phaseB
|
|
2407
|
+
// spent real money).
|
|
2408
|
+
// run.result.cost_usd_from_log = original shell-log value (preserved for
|
|
2409
|
+
// audit/provenance).
|
|
2410
|
+
// run.result.cost_usd_* = orch/subagent/estimated lanes for the
|
|
2411
|
+
// 4-lane tooltip the Cost column renders.
|
|
2412
|
+
// Runs whose `script` isn't in _PHASE_FAMILY get NO breakdown (we don't know
|
|
2413
|
+
// which sessions to attribute) and keep their shell-log cost.
|
|
2414
|
+
//
|
|
2415
|
+
// cycle_id disambiguation (2026-05-15): time-window + family matching alone
|
|
2416
|
+
// mis-attributes overlapping cycles. Twitter cycles fire every 15 min, so a
|
|
2417
|
+
// 30-min run's window catches the previous cycle's late-running prep and the
|
|
2418
|
+
// next cycle's scan. Each wrapper invocation exports a unique SA_CYCLE_ID
|
|
2419
|
+
// that every child claude_sessions row inherits, so after window+family
|
|
2420
|
+
// matching we pick the ONE cycle_id whose earliest session is closest to the
|
|
2421
|
+
// run's started_at (that's the run's own scan/first phase) and drop every
|
|
2422
|
+
// session belonging to a different cycle_id. Sessions with NULL cycle_id are
|
|
2423
|
+
// only used when the run matched no cycle_id at all (pure fallback).
|
|
2424
|
+
async function enrichRunsCostBreakdown(runs) {
|
|
2425
|
+
const candidates = (runs || []).filter(r =>
|
|
2426
|
+
!r.running && r.started_at && r.finished_at && _phaseFamilyFor(r.script)
|
|
2427
|
+
);
|
|
2428
|
+
if (!candidates.length) return;
|
|
2429
|
+
let minStart = Infinity, maxEnd = 0;
|
|
2430
|
+
for (const r of candidates) {
|
|
2431
|
+
const s = Date.parse(r.started_at);
|
|
2432
|
+
const e = Date.parse(r.finished_at);
|
|
2433
|
+
if (Number.isFinite(s) && s < minStart) minStart = s;
|
|
2434
|
+
if (Number.isFinite(e) && e > maxEnd) maxEnd = e;
|
|
2435
|
+
}
|
|
2436
|
+
if (minStart === Infinity) return;
|
|
2437
|
+
const slackMs = _RUN_WINDOW_SLACK_MS;
|
|
2438
|
+
const since = new Date(minStart - slackMs).toISOString();
|
|
2439
|
+
const until = new Date(maxEnd + slackMs).toISOString();
|
|
2440
|
+
let rows;
|
|
2441
|
+
try {
|
|
2442
|
+
rows = await pq(
|
|
2443
|
+
// SDK-only mode: orchestrator_cost_usd is the only number we display.
|
|
2444
|
+
// Subagent and transcript-estimate are kept for diagnostic tooltips but
|
|
2445
|
+
// never added into the total. has_sdk lets the UI distinguish "session
|
|
2446
|
+
// happened but SDK didn't capture cost" from "session cost was $0".
|
|
2447
|
+
"SELECT script, started_at, cycle_id, " +
|
|
2448
|
+
"orchestrator_cost_usd IS NOT NULL AS has_sdk, " +
|
|
2449
|
+
"COALESCE(orchestrator_cost_usd, 0)::float8 AS orch, " +
|
|
2450
|
+
"COALESCE(total_cost_usd, 0)::float8 AS est, " +
|
|
2451
|
+
"COALESCE(subagent_cost_usd, 0)::float8 AS sub " +
|
|
2452
|
+
"FROM claude_sessions WHERE started_at BETWEEN $1::timestamp AND $2::timestamp",
|
|
2453
|
+
[since, until]
|
|
2454
|
+
);
|
|
2455
|
+
} catch (e) {
|
|
2456
|
+
console.error('[enrichRunsCostBreakdown] query failed:', e && e.message || e);
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (!rows || !rows.length) return;
|
|
2460
|
+
const sessionList = rows.map(r => ({
|
|
2461
|
+
ts: r.started_at instanceof Date ? r.started_at.getTime() : Date.parse(r.started_at),
|
|
2462
|
+
script: r.script || '(unknown)',
|
|
2463
|
+
cycleId: r.cycle_id || null,
|
|
2464
|
+
hasSdk: !!r.has_sdk,
|
|
2465
|
+
orch: Number(r.orch) || 0,
|
|
2466
|
+
est: Number(r.est) || 0,
|
|
2467
|
+
sub: Number(r.sub) || 0,
|
|
2468
|
+
// SDK-only: total = orch. Sessions without SDK contribute 0 to total
|
|
2469
|
+
// (they're flagged via hasSdk so the tooltip can show coverage).
|
|
2470
|
+
total: Number(r.orch) || 0,
|
|
2471
|
+
}));
|
|
2472
|
+
for (const r of candidates) {
|
|
2473
|
+
const runStartMs = Date.parse(r.started_at);
|
|
2474
|
+
const startMs = runStartMs - slackMs;
|
|
2475
|
+
const endMs = Date.parse(r.finished_at) + slackMs;
|
|
2476
|
+
const family = _phaseFamilyFor(r.script);
|
|
2477
|
+
// Step 1: window + family match (over-broad — catches overlapping cycles).
|
|
2478
|
+
const windowMatched = sessionList.filter(s =>
|
|
2479
|
+
s.ts >= startMs && s.ts <= endMs && (!family || family.has(s.script))
|
|
2480
|
+
);
|
|
2481
|
+
// Step 2: cycle_id disambiguation. Group matched sessions by cycle_id and
|
|
2482
|
+
// pick the group whose earliest session starts closest to this run's
|
|
2483
|
+
// started_at — that's the run's own cycle. Drop sessions from other
|
|
2484
|
+
// cycle_ids. If no session carries a cycle_id, fall back to the full
|
|
2485
|
+
// window-matched set (older sessions / pipelines that don't set
|
|
2486
|
+
// SA_CYCLE_ID).
|
|
2487
|
+
let attributed;
|
|
2488
|
+
const byCycle = new Map();
|
|
2489
|
+
for (const s of windowMatched) {
|
|
2490
|
+
if (!s.cycleId) continue;
|
|
2491
|
+
let g = byCycle.get(s.cycleId);
|
|
2492
|
+
if (!g) { g = { earliest: s.ts, sessions: [] }; byCycle.set(s.cycleId, g); }
|
|
2493
|
+
if (s.ts < g.earliest) g.earliest = s.ts;
|
|
2494
|
+
g.sessions.push(s);
|
|
2495
|
+
}
|
|
2496
|
+
if (byCycle.size > 0) {
|
|
2497
|
+
let bestCycle = null, bestDelta = Infinity;
|
|
2498
|
+
for (const [cid, g] of byCycle) {
|
|
2499
|
+
const delta = Math.abs(g.earliest - runStartMs);
|
|
2500
|
+
if (delta < bestDelta) { bestDelta = delta; bestCycle = cid; }
|
|
2501
|
+
}
|
|
2502
|
+
attributed = byCycle.get(bestCycle).sessions;
|
|
2503
|
+
} else {
|
|
2504
|
+
attributed = windowMatched;
|
|
2505
|
+
}
|
|
2506
|
+
const byPhase = {};
|
|
2507
|
+
let totalOrch = 0, totalEst = 0, totalSub = 0;
|
|
2508
|
+
let sessionsAll = 0, sessionsWithSdk = 0;
|
|
2509
|
+
for (const s of attributed) {
|
|
2510
|
+
const key = s.script;
|
|
2511
|
+
const cur = byPhase[key] || { phase: key, sessions: 0, sessions_with_sdk: 0, orch: 0, est: 0, sub: 0 };
|
|
2512
|
+
cur.sessions += 1;
|
|
2513
|
+
if (s.hasSdk) cur.sessions_with_sdk += 1;
|
|
2514
|
+
cur.orch += s.orch;
|
|
2515
|
+
cur.est += s.est;
|
|
2516
|
+
cur.sub += s.sub;
|
|
2517
|
+
byPhase[key] = cur;
|
|
2518
|
+
sessionsAll += 1;
|
|
2519
|
+
if (s.hasSdk) sessionsWithSdk += 1;
|
|
2520
|
+
totalOrch += s.orch;
|
|
2521
|
+
totalEst += s.est;
|
|
2522
|
+
totalSub += s.sub;
|
|
2523
|
+
}
|
|
2524
|
+
if (!sessionsAll) continue;
|
|
2525
|
+
const phases = Object.values(byPhase)
|
|
2526
|
+
.sort((a, b) => b.orch - a.orch || b.sessions - a.sessions)
|
|
2527
|
+
.map(p => ({
|
|
2528
|
+
phase: p.phase,
|
|
2529
|
+
sessions: p.sessions,
|
|
2530
|
+
sessions_with_sdk: p.sessions_with_sdk,
|
|
2531
|
+
sessions_missing_sdk: p.sessions - p.sessions_with_sdk,
|
|
2532
|
+
total: Number(p.orch.toFixed(6)),
|
|
2533
|
+
orch: Number(p.orch.toFixed(6)),
|
|
2534
|
+
sub: Number(p.sub.toFixed(6)),
|
|
2535
|
+
est: Number(p.est.toFixed(6)),
|
|
2536
|
+
}));
|
|
2537
|
+
if (!r.result) r.result = {};
|
|
2538
|
+
// Preserve provenance: the original shell-log cost (parseFloat'd from the
|
|
2539
|
+
// run_monitor line) is kept under cost_usd_from_log so an operator can
|
|
2540
|
+
// see what the wrapper reported vs what we recomputed.
|
|
2541
|
+
if (typeof r.result.cost_usd === 'number') {
|
|
2542
|
+
r.result.cost_usd_from_log = r.result.cost_usd;
|
|
2543
|
+
}
|
|
2544
|
+
// SDK-only displayed total. Sessions missing SDK contribute 0 — the
|
|
2545
|
+
// sessions_missing_sdk counter on each phase row is the only signal that
|
|
2546
|
+
// real spend went unrecorded.
|
|
2547
|
+
r.result.cost_breakdown = {
|
|
2548
|
+
total: Number(totalOrch.toFixed(6)),
|
|
2549
|
+
orchestrator: Number(totalOrch.toFixed(6)),
|
|
2550
|
+
subagent: Number(totalSub.toFixed(6)),
|
|
2551
|
+
estimated: Number(totalEst.toFixed(6)),
|
|
2552
|
+
sessions: sessionsAll,
|
|
2553
|
+
sessions_with_sdk: sessionsWithSdk,
|
|
2554
|
+
sessions_missing_sdk: sessionsAll - sessionsWithSdk,
|
|
2555
|
+
phases,
|
|
2556
|
+
};
|
|
2557
|
+
r.result.cost_usd = r.result.cost_breakdown.total;
|
|
2558
|
+
r.result.cost_usd_orchestrator = r.result.cost_breakdown.orchestrator;
|
|
2559
|
+
r.result.cost_usd_estimated = r.result.cost_breakdown.estimated;
|
|
2560
|
+
r.result.cost_usd_subagent = r.result.cost_breakdown.subagent;
|
|
2561
|
+
r.result.cost_sessions_missing_sdk = r.result.cost_breakdown.sessions_missing_sdk;
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2287
2565
|
// DB-backed enrichment uses run.started_at and run.finished_at to define a
|
|
2288
2566
|
// window, plus a small slack on each side for clock skew between the shell
|
|
2289
2567
|
// trap that wrote the run_monitor line and the page row's completed_at.
|
|
@@ -3526,6 +3804,7 @@ async function handleApi(req, res) {
|
|
|
3526
3804
|
await enrichPostCommentsTwitterRuns(runs);
|
|
3527
3805
|
await enrichPostCommentsRedditRuns(runs);
|
|
3528
3806
|
await enrichSeoRuns(runs);
|
|
3807
|
+
await enrichRunsCostBreakdown(runs);
|
|
3529
3808
|
// Prepend in-progress pipelines so they appear at the top of the table.
|
|
3530
3809
|
// Always included regardless of the hours window — a long-running job
|
|
3531
3810
|
// started before the window is still relevant right now.
|
|
@@ -3632,28 +3911,37 @@ async function handleApi(req, res) {
|
|
|
3632
3911
|
"), session_counts AS (" +
|
|
3633
3912
|
"SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id" +
|
|
3634
3913
|
"), session_cost AS (" +
|
|
3914
|
+
// SDK-only mode (2026-05-15): per_row_cost = orchestrator_cost_usd
|
|
3915
|
+
// alone, split evenly across activity rows. NULL when the wrapper
|
|
3916
|
+
// didn't pass --orchestrator-cost-usd (e.g. shell wrappers that omit
|
|
3917
|
+
// --output-format json so Claude never emits total_cost_usd). The
|
|
3918
|
+
// transcript estimate and subagent dollars are computed from a local
|
|
3919
|
+
// pricing table — kept in the JSON payload for diagnostics only,
|
|
3920
|
+
// never folded into the displayed total.
|
|
3635
3921
|
"SELECT cs.session_id, " +
|
|
3636
|
-
"(
|
|
3922
|
+
"(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost, " +
|
|
3637
3923
|
"(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 " +
|
|
3924
|
+
"(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_estimated, " +
|
|
3925
|
+
"(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(10,6) AS per_row_cost_subagent " +
|
|
3639
3926
|
"FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id" +
|
|
3640
3927
|
") " +
|
|
3641
3928
|
"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
|
|
3929
|
+
"SELECT * FROM (SELECT posted_at AS occurred_at, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN 'posted_thread' ELSE 'posted_comment' END AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, engagement_style AS detail, our_url AS link, ('p' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c ON c.id = posts.campaign_id WHERE posted_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY posted_at DESC LIMIT 150) x1 " +
|
|
3930
|
+
"UNION ALL SELECT * FROM (SELECT r2.replied_at, 'replied', r2.platform, r2.their_author, COALESCE(LEFT(r2.our_reply_content, 280), LEFT(r2.their_content, 280)), CASE WHEN r2.is_recommendation THEN 'rec · ' || COALESCE(r2.engagement_style, '') ELSE r2.engagement_style END, r2.our_reply_url, ('r' || r2.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c2.name, p.thread_title, p.thread_url, NULL::text FROM replies r2 LEFT JOIN posts p ON p.id = r2.post_id LEFT JOIN session_cost sc ON sc.session_id = r2.claude_session_id LEFT JOIN campaigns c2 ON c2.id = r2.campaign_id WHERE r2.status='replied' AND r2.replied_at IS NOT NULL ORDER BY r2.replied_at DESC LIMIT 150) x2 " +
|
|
3931
|
+
"UNION ALL SELECT * FROM (SELECT COALESCE(r3.processing_at, r3.discovered_at), 'skipped', r3.platform, r3.their_author, LEFT(r3.their_content, 140), r3.skip_reason, r3.their_comment_url, ('s' || r3.id), p.project_name, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c3.name, p.thread_title, p.thread_url, NULL::text FROM replies r3 LEFT JOIN posts p ON p.id = r3.post_id LEFT JOIN session_cost sc ON sc.session_id = r3.claude_session_id LEFT JOIN campaigns c3 ON c3.id = r3.campaign_id WHERE r3.status='skipped' ORDER BY COALESCE(r3.processing_at, r3.discovered_at) DESC LIMIT 150) x3 " +
|
|
3932
|
+
"UNION ALL SELECT * FROM (SELECT COALESCE(source_timestamp, received_at), 'mention', platform, author, COALESCE(title, LEFT(body, 140)), sentiment, url, ('m' || id), NULL::text, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM octolens_mentions ORDER BY COALESCE(source_timestamp, received_at) DESC LIMIT 150) x4 " +
|
|
3933
|
+
"UNION ALL SELECT * FROM (SELECT sent_at, 'dm_sent', platform, their_author, LEFT(our_dm_content, 140), NULL::text, chat_url, ('d' || dms.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id WHERE status='sent' AND sent_at IS NOT NULL ORDER BY sent_at DESC LIMIT 150) x5 " +
|
|
3934
|
+
"UNION ALL SELECT * FROM (SELECT m.message_at, 'dm_reply_sent', d.platform, d.their_author, LEFT(m.content, 140), NULL::text, d.chat_url, ('dr' || m.id), NULL::text, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, c5.name, NULL::text, NULL::text, NULL::text FROM dm_messages m JOIN dms d ON d.id = m.dm_id LEFT JOIN session_cost sc ON sc.session_id = m.claude_session_id LEFT JOIN campaigns c5 ON c5.id = m.campaign_id WHERE m.direction = 'outbound' AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction = 'inbound' AND m2.message_at < m.message_at) ORDER BY m.message_at DESC LIMIT 150) x5b " +
|
|
3935
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_twitter', 'seo', product, keyword, slug, page_url, ('k' || sk.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk LEFT JOIN session_cost sc ON sc.session_id = sk.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'twitter' ORDER BY completed_at DESC LIMIT 150) x6 " +
|
|
3936
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_misc', 'seo', product, keyword, slug, page_url, ('km' || sk6.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk6 LEFT JOIN session_cost sc ON sc.session_id = sk6.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter') ORDER BY completed_at DESC LIMIT 150) x6m " +
|
|
3937
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_gsc', 'seo', product, query, page_slug, page_url, ('g' || gq.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM gsc_queries gq LEFT JOIN session_cost sc ON sc.session_id = gq.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL ORDER BY completed_at DESC LIMIT 150) x7 " +
|
|
3938
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_reddit', 'seo', product, keyword, slug, page_url, ('kr' || sk2.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk2 LEFT JOIN session_cost sc ON sc.session_id = sk2.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'reddit' ORDER BY completed_at DESC LIMIT 150) x8 " +
|
|
3939
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top', 'seo', product, keyword, slug, page_url, ('kt' || sk3.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk3 LEFT JOIN session_cost sc ON sc.session_id = sk3.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_page' ORDER BY completed_at DESC LIMIT 150) x8b " +
|
|
3940
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_top_post', 'seo', product, keyword, slug, page_url, ('ktp' || sk5.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk5 LEFT JOIN session_cost sc ON sc.session_id = sk5.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'top_post' ORDER BY completed_at DESC LIMIT 150) x8tp " +
|
|
3941
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_published_roundup', 'seo', product, keyword, slug, page_url, ('kru' || sk4.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_keywords sk4 LEFT JOIN session_cost sc ON sc.session_id = sk4.claude_session_id WHERE completed_at IS NOT NULL AND page_url IS NOT NULL AND source = 'roundup' ORDER BY completed_at DESC LIMIT 150) x8r " +
|
|
3942
|
+
"UNION ALL SELECT * FROM (SELECT completed_at, 'page_improved', 'seo', product, LEFT(COALESCE(rationale, diff_summary, page_path), 140), page_path, page_url, ('pi' || spi.id), product, sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_page_improvements spi LEFT JOIN session_cost sc ON sc.session_id = spi.claude_session_id WHERE completed_at IS NOT NULL AND status = 'committed' ORDER BY completed_at DESC LIMIT 150) x8c " +
|
|
3943
|
+
"UNION ALL SELECT * FROM (SELECT expired_at, 'page_expired', 'seo', product, regexp_replace(source_path, '^.*/', ''), 'imp=' || impressions_30d || ' clicks=0 age=' || COALESCE(file_age_days::int, 0) || 'd ' || COALESCE(reason,''), page_url, ('xp' || sep.id), product, NULL::numeric, NULL::numeric, NULL::numeric, NULL::numeric, NULL::text, NULL::text, NULL::text, NULL::text FROM seo_expired_pages sep ORDER BY expired_at DESC LIMIT 150) x8d " +
|
|
3944
|
+
"UNION ALL SELECT * FROM (SELECT resurrected_at AS occurred_at, 'resurrected' AS type, platform, our_account AS actor, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN COALESCE(thread_title, LEFT(our_content, 280)) ELSE LEFT(our_content, 280) END AS summary, NULL::text AS detail, our_url AS link, ('rr' || posts.id) AS key, project_name AS project, sc.per_row_cost AS cost_usd, sc.per_row_cost_orchestrator AS cost_usd_orchestrator, sc.per_row_cost_estimated AS cost_usd_estimated, sc.per_row_cost_subagent AS cost_usd_subagent, c9.name AS campaign_name, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_title END AS context_title, CASE WHEN thread_url = our_url AND (thread_author IS NULL OR thread_author = our_account) THEN NULL ELSE thread_url END AS context_url, LEFT(our_content, 3000) AS body FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id LEFT JOIN campaigns c9 ON c9.id = posts.campaign_id WHERE resurrected_at IS NOT NULL AND our_content <> '(mention - no original post)' ORDER BY resurrected_at DESC LIMIT 150) x9 " +
|
|
3657
3945
|
"ORDER BY 1 DESC LIMIT 500) r";
|
|
3658
3946
|
return (async () => {
|
|
3659
3947
|
const rows = await pq(q);
|
|
@@ -3668,6 +3956,7 @@ async function handleApi(req, res) {
|
|
|
3668
3956
|
delete e.cost_usd;
|
|
3669
3957
|
delete e.cost_usd_orchestrator;
|
|
3670
3958
|
delete e.cost_usd_estimated;
|
|
3959
|
+
delete e.cost_usd_subagent;
|
|
3671
3960
|
});
|
|
3672
3961
|
}
|
|
3673
3962
|
return json(res, { events });
|
|
@@ -4158,7 +4447,16 @@ async function handleApi(req, res) {
|
|
|
4158
4447
|
// started with a prospect."
|
|
4159
4448
|
parts.push("SELECT 'dm_sent' AS type, d.platform AS pl FROM dms d WHERE EXISTS (SELECT 1 FROM dm_messages m WHERE m.dm_id = d.id AND m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND NOT EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = d.id AND m2.direction='outbound' AND m2.message_at < m.message_at))" + dmsAliasedPc.clause);
|
|
4160
4449
|
parts.push("SELECT 'dm_reply_sent' AS type, d.platform AS pl FROM dm_messages m JOIN dms d ON d.id = m.dm_id WHERE m.direction='outbound' AND m.message_at >= NOW() - " + win + " AND EXISTS (SELECT 1 FROM dm_messages m2 WHERE m2.dm_id = m.dm_id AND m2.direction='inbound' AND m2.message_at < m.message_at)" + dmsAliasedPc.clause);
|
|
4161
|
-
|
|
4450
|
+
// Pre-2026-05-16: a single 'page_published_serp' bucket caught every
|
|
4451
|
+
// seo_keywords row whose source was not (reddit, top_page, top_post,
|
|
4452
|
+
// roundup). The real SERP pipeline was unloaded 2026-04-17; the cards
|
|
4453
|
+
// labelled "SERP SEO" since then have actually been Twitter-cycle
|
|
4454
|
+
// page-gen output (twitter_gen_links.py -> generate_page.py --trigger
|
|
4455
|
+
// twitter, source='twitter'). Split into an honest twitter bucket and a
|
|
4456
|
+
// misc catch-all for legacy/edge sources (existing_page, gsc,
|
|
4457
|
+
// suggestion:*, competitor:*, topic_template, etc).
|
|
4458
|
+
parts.push("SELECT 'page_published_twitter' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source = 'twitter'" + seoProdPc.clause);
|
|
4459
|
+
parts.push("SELECT 'page_published_misc' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND COALESCE(source, '') NOT IN ('reddit', 'top_page', 'top_post', 'roundup', 'twitter')" + seoProdPc.clause);
|
|
4162
4460
|
parts.push("SELECT 'page_published_gsc' AS type, 'seo' AS pl FROM gsc_queries WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL" + seoProdPc.clause);
|
|
4163
4461
|
parts.push("SELECT 'page_published_reddit' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='reddit'" + seoProdPc.clause);
|
|
4164
4462
|
parts.push("SELECT 'page_published_top' AS type, 'seo' AS pl FROM seo_keywords WHERE completed_at >= NOW() - " + win + " AND page_url IS NOT NULL AND source='top_page'" + seoProdPc.clause);
|
|
@@ -4354,10 +4652,18 @@ async function handleApi(req, res) {
|
|
|
4354
4652
|
const projectFilter = project
|
|
4355
4653
|
? " AND p.project_name = '" + project.replace(/'/g, "''") + "'"
|
|
4356
4654
|
: '';
|
|
4655
|
+
// Split posts_made into threads_made (we authored the thread itself) vs
|
|
4656
|
+
// comments_made (we engaged on someone else's thread). Matches the
|
|
4657
|
+
// /api/activity classifier: thread iff thread_url = our_url AND
|
|
4658
|
+
// (thread_author IS NULL OR thread_author = our_account).
|
|
4659
|
+
const threadClause =
|
|
4660
|
+
"p.thread_url = p.our_url AND (p.thread_author IS NULL OR p.thread_author = p.our_account)";
|
|
4357
4661
|
const q =
|
|
4358
4662
|
"SELECT json_agg(row_to_json(r)) FROM (" +
|
|
4359
4663
|
"SELECT to_char((p.posted_at AT TIME ZONE 'UTC')::date, 'YYYY-MM-DD') AS day, " +
|
|
4360
|
-
"COUNT(*)::bigint AS posts_made " +
|
|
4664
|
+
"COUNT(*)::bigint AS posts_made, " +
|
|
4665
|
+
"SUM(CASE WHEN " + threadClause + " THEN 1 ELSE 0 END)::bigint AS threads_made, " +
|
|
4666
|
+
"SUM(CASE WHEN " + threadClause + " THEN 0 ELSE 1 END)::bigint AS comments_made " +
|
|
4361
4667
|
"FROM posts p " +
|
|
4362
4668
|
"WHERE p.posted_at IS NOT NULL " +
|
|
4363
4669
|
"AND p.posted_at >= CURRENT_DATE - INTERVAL '" + days + " days' " +
|
|
@@ -4506,7 +4812,8 @@ async function handleApi(req, res) {
|
|
|
4506
4812
|
const sumCols =
|
|
4507
4813
|
"COALESCE(SUM(sc.per_row_cost), 0)::numeric(12,4) AS total_cost_usd, " +
|
|
4508
4814
|
"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"
|
|
4815
|
+
"COALESCE(SUM(sc.per_row_cost_estimated), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
|
|
4816
|
+
"COALESCE(SUM(sc.per_row_cost_subagent), 0)::numeric(12,4) AS total_cost_usd_subagent";
|
|
4510
4817
|
if (includeThread) {
|
|
4511
4818
|
rowQueries.push(
|
|
4512
4819
|
"SELECT 'thread' AS type, COUNT(*)::int AS count, " + sumCols + " " +
|
|
@@ -4545,15 +4852,46 @@ async function handleApi(req, res) {
|
|
|
4545
4852
|
"WITH src AS (" + parts.join(' UNION ALL ') + "), " +
|
|
4546
4853
|
"session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
|
|
4547
4854
|
"session_cost AS (SELECT cs.session_id, " +
|
|
4548
|
-
"(
|
|
4855
|
+
"(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
|
|
4549
4856
|
"(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 " +
|
|
4857
|
+
"(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
|
|
4858
|
+
"(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
|
|
4551
4859
|
"FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id) " +
|
|
4552
4860
|
"SELECT json_agg(row_to_json(r)) FROM (" + rowQueries.join(' UNION ALL ') + ") r";
|
|
4861
|
+
// Per-phase (script) cost rollup over the same window. Groups every
|
|
4862
|
+
// claude_sessions row in the window by its `script` column (e.g.
|
|
4863
|
+
// run-twitter-cycle-scan, post_reddit, seo_generate_page) and surfaces
|
|
4864
|
+
// total + orchestrator + subagent + estimate lanes. Independent of the
|
|
4865
|
+
// activity-type rollup above: a single post_reddit session might produce
|
|
4866
|
+
// 0 or 1 thread row, but its cost still shows up in the per-phase view.
|
|
4867
|
+
// SDK-only per-phase rollup. total_cost_usd = SUM(orchestrator_cost_usd)
|
|
4868
|
+
// — no transcript estimate, no subagent fold-in. Phases with 0% SDK
|
|
4869
|
+
// coverage (wrapper doesn't capture --orchestrator-cost-usd) show
|
|
4870
|
+
// total $0 but a non-zero sessions_missing_sdk count, which is the
|
|
4871
|
+
// signal to investigate. Also surfaces the estimate and subagent
|
|
4872
|
+
// columns as diagnostic-only fields.
|
|
4873
|
+
const phaseQ =
|
|
4874
|
+
"SELECT script AS phase, " +
|
|
4875
|
+
"COUNT(*)::int AS sessions, " +
|
|
4876
|
+
"COUNT(orchestrator_cost_usd)::int AS sessions_with_sdk, " +
|
|
4877
|
+
"(COUNT(*) - COUNT(orchestrator_cost_usd))::int AS sessions_missing_sdk, " +
|
|
4878
|
+
"COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd, " +
|
|
4879
|
+
"COALESCE(SUM(orchestrator_cost_usd), 0)::numeric(12,4) AS total_cost_usd_orchestrator, " +
|
|
4880
|
+
"COALESCE(SUM(total_cost_usd), 0)::numeric(12,4) AS total_cost_usd_estimated, " +
|
|
4881
|
+
"COALESCE(SUM(subagent_cost_usd), 0)::numeric(12,4) AS total_cost_usd_subagent " +
|
|
4882
|
+
"FROM claude_sessions " +
|
|
4883
|
+
"WHERE started_at >= NOW() - " + win + " " +
|
|
4884
|
+
"GROUP BY script " +
|
|
4885
|
+
"HAVING COUNT(*) > 0 " +
|
|
4886
|
+
"ORDER BY total_cost_usd DESC, sessions DESC " +
|
|
4887
|
+
"LIMIT 50";
|
|
4553
4888
|
return (async () => {
|
|
4554
4889
|
const dbRows = await pq(q);
|
|
4555
4890
|
const value = (dbRows && dbRows.length && dbRows[0].json_agg) ? dbRows[0].json_agg : [];
|
|
4556
|
-
|
|
4891
|
+
let phases = [];
|
|
4892
|
+
try { phases = await pq(phaseQ) || []; }
|
|
4893
|
+
catch (e) { console.error('[cost/stats] phase query failed:', e && e.message || e); }
|
|
4894
|
+
return json(res, { windowHours, platform: plat || 'all', rows: value, phases });
|
|
4557
4895
|
})().catch(e => json(res, { error: e.message }, 500));
|
|
4558
4896
|
}
|
|
4559
4897
|
|
|
@@ -4674,7 +5012,7 @@ async function handleApi(req, res) {
|
|
|
4674
5012
|
"WITH src AS (" + parts.join(' UNION ALL ') + "), " +
|
|
4675
5013
|
"session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
|
|
4676
5014
|
"session_cost AS (SELECT cs.session_id, " +
|
|
4677
|
-
"(
|
|
5015
|
+
"(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost " +
|
|
4678
5016
|
"FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
|
|
4679
5017
|
"in_window AS (" + inWindow.join(' UNION ALL ') + ") " +
|
|
4680
5018
|
"SELECT json_agg(row_to_json(r)) FROM (" +
|
|
@@ -5181,10 +5519,47 @@ async function handleApi(req, res) {
|
|
|
5181
5519
|
const pc = auth.projectClause(req.user, 'project_name', url.searchParams.get('project'));
|
|
5182
5520
|
if (!pc.ok) return json(res, { posts: [], window: windowKey, platform: platformFilter || 'all', kind: kindFilter });
|
|
5183
5521
|
if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
|
|
5522
|
+
// 2026-05-18 dashboard parity for Reddit DM-rail follow-ups.
|
|
5523
|
+
// Reply-to-reply rows (we replied back to someone who replied to our
|
|
5524
|
+
// top-level comment) live in the `replies` table, not `posts`, so they
|
|
5525
|
+
// were invisible on the Top tab even though they have real engagement
|
|
5526
|
+
// (some routinely break 1000 views). LinkedIn migrated the analogous
|
|
5527
|
+
// data into `posts` on 2026-05-11, but the (platform, thread_url) dedup
|
|
5528
|
+
// on /api/v1/posts blocks the same migration for Reddit (most reply-
|
|
5529
|
+
// to-replies share thread_url with our top-level post in the same
|
|
5530
|
+
// thread). UNION-into-/api/top sidesteps that entirely: the `replies`
|
|
5531
|
+
// table stays the source of truth, and the Top tab surfaces both
|
|
5532
|
+
// surfaces. The activity feed (line ~3919) already filters
|
|
5533
|
+
// r.status='replied' so reply-to-replies appear ONCE there.
|
|
5534
|
+
//
|
|
5535
|
+
// Build the same WHERE clause for the replies branch, but referencing
|
|
5536
|
+
// `r2.replied_at` for the time window and `parent.thread_url` semantics
|
|
5537
|
+
// for the kind filter. Replies are by definition never threads, so
|
|
5538
|
+
// kind='threads' must exclude the whole branch.
|
|
5539
|
+
const repliesPc = auth.projectClause(req.user, 'parent.project_name', url.searchParams.get('project'));
|
|
5540
|
+
const repliesWhere = [
|
|
5541
|
+
"r2.platform = 'reddit'", // only Reddit has the gap today
|
|
5542
|
+
"r2.status = 'replied'",
|
|
5543
|
+
"r2.our_reply_id IS NOT NULL",
|
|
5544
|
+
"r2.our_reply_url IS NOT NULL",
|
|
5545
|
+
"r2.our_reply_content IS NOT NULL AND LENGTH(r2.our_reply_content) >= 30",
|
|
5546
|
+
];
|
|
5547
|
+
if (windowHours != null) {
|
|
5548
|
+
repliesWhere.push("r2.replied_at >= NOW() - INTERVAL '" + windowHours + " hours'");
|
|
5549
|
+
}
|
|
5550
|
+
if (platformFilter && platformFilter !== 'reddit') {
|
|
5551
|
+
// Caller filtered to a non-Reddit platform; replies branch yields nothing.
|
|
5552
|
+
repliesWhere.push("FALSE");
|
|
5553
|
+
}
|
|
5554
|
+
// kind: 'threads' excludes replies entirely; 'comments' and 'all' include.
|
|
5555
|
+
if (kindFilter === 'threads') {
|
|
5556
|
+
repliesWhere.push("FALSE");
|
|
5557
|
+
}
|
|
5558
|
+
if (repliesPc.clause) repliesWhere.push(repliesPc.clause.replace(/^\s*AND\s+/, ''));
|
|
5184
5559
|
// Moltbook and GitHub have no views metric; return NULL for those so the UI can
|
|
5185
5560
|
// render a dash instead of a misleading 0. Score still uses COALESCE so they
|
|
5186
5561
|
// rank alongside other platforms based on upvotes + comments only.
|
|
5187
|
-
const
|
|
5562
|
+
const postsBranch =
|
|
5188
5563
|
"SELECT posts.id, posts.platform, " +
|
|
5189
5564
|
// Upvotes are reported NET on Reddit/Moltbook (both auto-apply a +1 OP
|
|
5190
5565
|
// self-upvote on every post). Strip it per row, clamped at 0 so
|
|
@@ -5216,7 +5591,8 @@ async function handleApi(req, res) {
|
|
|
5216
5591
|
"COALESCE(pl.bot_clicks, 0)::int AS link_bot_clicks, " +
|
|
5217
5592
|
"COALESCE(pl.backfill_real, 0)::int AS link_backfill_real, " +
|
|
5218
5593
|
"COALESCE(pl.link_count, 0)::int AS link_count, " +
|
|
5219
|
-
"pl.first_code AS link_code " +
|
|
5594
|
+
"pl.first_code AS link_code, " +
|
|
5595
|
+
"'post'::text AS row_kind " +
|
|
5220
5596
|
"FROM posts LEFT JOIN campaigns c ON c.id = posts.campaign_id " +
|
|
5221
5597
|
// pl rollup: legacy `total_clicks` reads the post_links.clicks integer
|
|
5222
5598
|
// (humans-only after 2026-05-07; pre-existing rows include bots).
|
|
@@ -5245,6 +5621,50 @@ async function handleApi(req, res) {
|
|
|
5245
5621
|
") pl ON pl.post_id = posts.id " +
|
|
5246
5622
|
"WHERE " + whereParts.join(' AND ') + " " +
|
|
5247
5623
|
"ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
|
|
5624
|
+
"LIMIT " + limit;
|
|
5625
|
+
// Replies branch: shape-compatible SELECT against `replies` JOIN `posts` (parent)
|
|
5626
|
+
// for thread context. ID is negated to guarantee uniqueness within the UNION
|
|
5627
|
+
// (posts.id and replies.id are independent sequences and would otherwise
|
|
5628
|
+
// collide for sort/key purposes). The FE only uses `id` as a React key, so
|
|
5629
|
+
// negative integers are fine.
|
|
5630
|
+
const repliesBranch =
|
|
5631
|
+
"SELECT (-r2.id)::int AS id, r2.platform, " +
|
|
5632
|
+
// Reddit-only branch today; strip the OP self-upvote like the posts branch.
|
|
5633
|
+
"GREATEST(0, COALESCE(r2.upvotes, 0) - 1)::int AS upvotes, " +
|
|
5634
|
+
"COALESCE(r2.comments_count, 0)::int AS comments_count, " +
|
|
5635
|
+
"COALESCE(r2.views, 0)::int AS views, " +
|
|
5636
|
+
// Same score formula. Views (Reddit only) contribute /100.
|
|
5637
|
+
"(COALESCE(r2.comments_count,0) * 5 " +
|
|
5638
|
+
"+ GREATEST(0, COALESCE(r2.upvotes,0) - 1) * 5 " +
|
|
5639
|
+
"+ COALESCE(r2.views,0) / 100)::int AS score, " +
|
|
5640
|
+
"FALSE AS is_thread, " +
|
|
5641
|
+
"r2.replied_at AS posted_at, " +
|
|
5642
|
+
"r2.engagement_updated_at, " +
|
|
5643
|
+
"r2.our_reply_content AS our_content, " +
|
|
5644
|
+
"r2.our_reply_url AS our_url, " +
|
|
5645
|
+
"parent.thread_url, parent.thread_title, " +
|
|
5646
|
+
"LEFT(COALESCE(parent.thread_content, ''), 400) AS thread_content, " +
|
|
5647
|
+
"parent.our_account, parent.project_name, " +
|
|
5648
|
+
"r2.engagement_style, r2.is_recommendation, " +
|
|
5649
|
+
"cr.name AS campaign_name, " +
|
|
5650
|
+
// No link tracking on reply-to-replies yet (we don't usually drop a CTA
|
|
5651
|
+
// there). All link counters return 0 so they sort to the bottom of
|
|
5652
|
+
// any link-clicks ordering.
|
|
5653
|
+
"0::int AS link_clicks, 0::int AS link_real_clicks, " +
|
|
5654
|
+
"0::int AS link_bot_clicks, 0::int AS link_backfill_real, " +
|
|
5655
|
+
"0::int AS link_count, NULL::text AS link_code, " +
|
|
5656
|
+
"'reply'::text AS row_kind " +
|
|
5657
|
+
"FROM replies r2 " +
|
|
5658
|
+
"LEFT JOIN posts parent ON parent.id = r2.post_id " +
|
|
5659
|
+
"LEFT JOIN campaigns cr ON cr.id = r2.campaign_id " +
|
|
5660
|
+
"WHERE " + repliesWhere.join(' AND ') + " " +
|
|
5661
|
+
"ORDER BY r2.upvotes DESC NULLS LAST, r2.comments_count DESC NULLS LAST, r2.views DESC NULLS LAST " +
|
|
5662
|
+
"LIMIT " + limit;
|
|
5663
|
+
const q = "SELECT json_agg(row_to_json(r)) FROM (" +
|
|
5664
|
+
"SELECT * FROM (" + postsBranch + ") posts_branch " +
|
|
5665
|
+
"UNION ALL " +
|
|
5666
|
+
"SELECT * FROM (" + repliesBranch + ") replies_branch " +
|
|
5667
|
+
"ORDER BY upvotes DESC NULLS LAST, comments_count DESC NULLS LAST, views DESC NULLS LAST " +
|
|
5248
5668
|
"LIMIT " + limit +
|
|
5249
5669
|
") r";
|
|
5250
5670
|
return (async () => {
|
|
@@ -5254,6 +5674,134 @@ async function handleApi(req, res) {
|
|
|
5254
5674
|
})().catch(e => json(res, { error: e.message }, 500));
|
|
5255
5675
|
}
|
|
5256
5676
|
|
|
5677
|
+
// GET /api/top/destinations - post links rolled up by target URL.
|
|
5678
|
+
// One row per unique destination URL (e.g. https://s4l.ai vs
|
|
5679
|
+
// https://s4l.ai/ghostwriting vs https://s4l.ai/t/<slug>), with click totals
|
|
5680
|
+
// aggregated across every short code that pointed at that URL. Used by the
|
|
5681
|
+
// "Links" subtab in the Top tab to answer "where are my posts sending
|
|
5682
|
+
// traffic and how many clicks does each destination get?"
|
|
5683
|
+
if (p === '/api/top/destinations' && req.method === 'GET') {
|
|
5684
|
+
const url = new URL(req.url, 'http://localhost');
|
|
5685
|
+
const WINDOW_HOURS = { '24h': 24, '7d': 24*7, '14d': 24*14, '30d': 24*30, '90d': 24*90, 'all': null };
|
|
5686
|
+
const rawWindow = String(url.searchParams.get('window') || '7d').toLowerCase();
|
|
5687
|
+
const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
|
|
5688
|
+
const windowHours = WINDOW_HOURS[windowKey];
|
|
5689
|
+
const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
|
|
5690
|
+
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
|
|
5691
|
+
const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
|
|
5692
|
+
const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
|
|
5693
|
+
if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
|
|
5694
|
+
const limit = Math.max(50, Math.min(500, parseInt(url.searchParams.get('limit') || '200', 10) || 200));
|
|
5695
|
+
const whereParts = [];
|
|
5696
|
+
if (windowHours != null) {
|
|
5697
|
+
whereParts.push("pl.minted_at >= NOW() - INTERVAL '" + windowHours + " hours'");
|
|
5698
|
+
}
|
|
5699
|
+
if (platformFilter) {
|
|
5700
|
+
whereParts.push("LOWER(pl.platform) = '" + platformFilter + "'");
|
|
5701
|
+
}
|
|
5702
|
+
if (pc.clause) whereParts.push(pc.clause.replace(/^\s*AND\s+/, ''));
|
|
5703
|
+
const whereSql = whereParts.length ? ('WHERE ' + whereParts.join(' AND ')) : '';
|
|
5704
|
+
// Grouping key is the URL with all query params + trailing slash stripped.
|
|
5705
|
+
// UTM params (utm_source / utm_medium / utm_campaign / utm_term /
|
|
5706
|
+
// utm_content) are baked into every target_url at mint time so each
|
|
5707
|
+
// post gets its own uniquely-tracked URL even when posting at the same
|
|
5708
|
+
// destination; without stripping, the same homepage would split into
|
|
5709
|
+
// 50 rows. Path is preserved (so /ghostwriting stays distinct from /).
|
|
5710
|
+
// Project + platform stay in GROUP BY so a multi-project repo (one
|
|
5711
|
+
// website hosting pages for several projects) keeps them on separate
|
|
5712
|
+
// rows.
|
|
5713
|
+
const q = "SELECT json_agg(row_to_json(r)) FROM (" +
|
|
5714
|
+
"SELECT " +
|
|
5715
|
+
"REGEXP_REPLACE(REGEXP_REPLACE(pl.target_url, '\\?.*$', ''), '/$', '') AS target_url, " +
|
|
5716
|
+
"pl.project_name, pl.platform, " +
|
|
5717
|
+
"COUNT(DISTINCT pl.post_id)::int AS posts, " +
|
|
5718
|
+
"COUNT(*)::int AS codes, " +
|
|
5719
|
+
"COALESCE(SUM(pl.clicks), 0)::int AS legacy_clicks, " +
|
|
5720
|
+
"COALESCE(SUM(plc.real_clicks), 0)::int AS real_clicks, " +
|
|
5721
|
+
"COALESCE(SUM(plc.bot_clicks), 0)::int AS bot_clicks, " +
|
|
5722
|
+
"COALESCE(SUM(pl.real_clicks), 0)::int AS backfill_real, " +
|
|
5723
|
+
"MIN(pl.minted_at) AS first_minted_at, " +
|
|
5724
|
+
"MAX(pl.last_click_at) AS last_click_at " +
|
|
5725
|
+
"FROM post_links pl " +
|
|
5726
|
+
"LEFT JOIN (" +
|
|
5727
|
+
"SELECT code, " +
|
|
5728
|
+
" COUNT(*) FILTER (WHERE is_bot = false)::int AS real_clicks, " +
|
|
5729
|
+
" COUNT(*) FILTER (WHERE is_bot = true)::int AS bot_clicks " +
|
|
5730
|
+
"FROM post_link_clicks GROUP BY code" +
|
|
5731
|
+
") plc ON plc.code = pl.code " +
|
|
5732
|
+
whereSql + " " +
|
|
5733
|
+
"GROUP BY 1, pl.project_name, pl.platform " +
|
|
5734
|
+
"ORDER BY real_clicks DESC NULLS LAST, legacy_clicks DESC NULLS LAST, codes DESC " +
|
|
5735
|
+
"LIMIT " + limit +
|
|
5736
|
+
") r";
|
|
5737
|
+
return (async () => {
|
|
5738
|
+
const rows = await pq(q);
|
|
5739
|
+
const destinations = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
|
|
5740
|
+
// Server-side classification of each destination URL into a kind bucket
|
|
5741
|
+
// and (when applicable) the audience-page angle. Reads config.json
|
|
5742
|
+
// once per request to look up each project's website host + audience
|
|
5743
|
+
// pages list; classification is plain hostname / path matching. Done
|
|
5744
|
+
// here so every consumer (UI, future CSV exports) sees the same
|
|
5745
|
+
// canonical label without having to re-implement classify logic.
|
|
5746
|
+
let cfg = null;
|
|
5747
|
+
try {
|
|
5748
|
+
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
5749
|
+
} catch (_e) { cfg = { projects: [] }; }
|
|
5750
|
+
const projIdx = {};
|
|
5751
|
+
for (const p of (cfg && cfg.projects) || []) {
|
|
5752
|
+
if (!p || !p.name) continue;
|
|
5753
|
+
const host = String(p.website || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase();
|
|
5754
|
+
const audience = ((p.landing_pages || {}).audience_pages) || [];
|
|
5755
|
+
const apIdx = [];
|
|
5756
|
+
for (const a of audience) {
|
|
5757
|
+
try {
|
|
5758
|
+
const u = new URL(a.url);
|
|
5759
|
+
apIdx.push({
|
|
5760
|
+
angle: a.angle,
|
|
5761
|
+
host: (u.hostname || '').toLowerCase().replace(/^www\./, ''),
|
|
5762
|
+
path: (u.pathname || '/').replace(/\/+$/, '') || '/',
|
|
5763
|
+
});
|
|
5764
|
+
} catch (_e) {}
|
|
5765
|
+
}
|
|
5766
|
+
projIdx[p.name] = { website_host: host, audience_pages: apIdx };
|
|
5767
|
+
}
|
|
5768
|
+
const classify = (targetUrl, projectName) => {
|
|
5769
|
+
if (!targetUrl) return { kind: 'other', audience_page_angle: null };
|
|
5770
|
+
let host = '', pathName = '/';
|
|
5771
|
+
try {
|
|
5772
|
+
const u = new URL(targetUrl);
|
|
5773
|
+
host = (u.hostname || '').toLowerCase().replace(/^www\./, '');
|
|
5774
|
+
pathName = (u.pathname || '/').replace(/\/+$/, '') || '/';
|
|
5775
|
+
} catch (_e) { return { kind: 'other', audience_page_angle: null }; }
|
|
5776
|
+
if (/(^|\.)cal\.com$/.test(host) || /(^|\.)calendly\.com$/.test(host)) return { kind: 'booking', audience_page_angle: null };
|
|
5777
|
+
if (host === 'github.com') return { kind: 'github', audience_page_angle: null };
|
|
5778
|
+
const entry = projectName ? projIdx[projectName] : null;
|
|
5779
|
+
// Audience-page exact host+path match wins over generic SUBPAGE.
|
|
5780
|
+
if (entry) {
|
|
5781
|
+
for (const ap of entry.audience_pages || []) {
|
|
5782
|
+
if (ap.host === host && ap.path === pathName) {
|
|
5783
|
+
return { kind: 'audience_page', audience_page_angle: ap.angle || null };
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
if (entry.website_host && host === entry.website_host) {
|
|
5787
|
+
if (pathName === '/' || pathName === '') return { kind: 'home', audience_page_angle: null };
|
|
5788
|
+
if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
|
|
5789
|
+
return { kind: 'subpage', audience_page_angle: null };
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
if (pathName === '/' || pathName === '') return { kind: 'other', audience_page_angle: null };
|
|
5793
|
+
if (/^\/t\//.test(pathName)) return { kind: 'seo', audience_page_angle: null };
|
|
5794
|
+
return { kind: 'external', audience_page_angle: null };
|
|
5795
|
+
};
|
|
5796
|
+
for (const d of destinations) {
|
|
5797
|
+
const c = classify(d.target_url, d.project_name);
|
|
5798
|
+
d.kind = c.kind;
|
|
5799
|
+
d.audience_page_angle = c.audience_page_angle;
|
|
5800
|
+
}
|
|
5801
|
+
return json(res, { destinations, window: windowKey, platform: platformFilter || 'all' });
|
|
5802
|
+
})().catch(e => json(res, { error: e.message }, 500));
|
|
5803
|
+
}
|
|
5804
|
+
|
|
5257
5805
|
// GET /api/top/links - post short links ranked by click count.
|
|
5258
5806
|
// Queries post_links joined with posts so the content snippet is available.
|
|
5259
5807
|
// Returns links with >= 1 click, ordered by clicks desc. Used by the "Links"
|
|
@@ -5486,11 +6034,23 @@ async function handleApi(req, res) {
|
|
|
5486
6034
|
"AND query IS NOT NULL AND length(trim(query)) > 0 " +
|
|
5487
6035
|
"), " +
|
|
5488
6036
|
"cand AS ( " +
|
|
5489
|
-
|
|
6037
|
+
// Twitter: c.search_topic is the SEED concept (e.g. "vibe coding") while
|
|
6038
|
+
// twitter_search_attempts.query is the literal X advanced-search string
|
|
6039
|
+
// (e.g. '("vibe coded" OR ...) min_faves:30 since:... -filter:replies').
|
|
6040
|
+
// The two never line up textually, so we associate each candidate to
|
|
6041
|
+
// its parent attempt via (batch_id, project_name) and project the
|
|
6042
|
+
// attempt's `query` as the join key. When one project ran multiple
|
|
6043
|
+
// queries in the same batch (~1.5% of cases), this attributes each
|
|
6044
|
+
// candidate to all of them — known minor over-attribution, acceptable
|
|
6045
|
+
// until we add a per-attempt seed column.
|
|
6046
|
+
"SELECT 'twitter' AS platform, a.query, " +
|
|
5490
6047
|
"COALESCE(c.matched_project, '(none)') AS project_name, c.post_id " +
|
|
5491
6048
|
"FROM twitter_candidates c " +
|
|
6049
|
+
"JOIN twitter_search_attempts a " +
|
|
6050
|
+
"ON a.batch_id = c.batch_id " +
|
|
6051
|
+
"AND COALESCE(a.project_name, '(none)') = COALESCE(c.matched_project, '(none)') " +
|
|
5492
6052
|
"WHERE c.discovered_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
|
|
5493
|
-
"AND c.
|
|
6053
|
+
"AND c.batch_id IS NOT NULL " +
|
|
5494
6054
|
"UNION ALL " +
|
|
5495
6055
|
"SELECT 'linkedin', c.search_query, COALESCE(c.matched_project, '(none)'), c.post_id " +
|
|
5496
6056
|
"FROM linkedin_candidates c " +
|
|
@@ -5680,7 +6240,7 @@ async function handleApi(req, res) {
|
|
|
5680
6240
|
// suppresses the column. SDK and estimate lanes are surfaced separately
|
|
5681
6241
|
// so the dashboard tooltip can show both, same UX as cost-stats.
|
|
5682
6242
|
let costByProject = {};
|
|
5683
|
-
let grandCost = 0, grandCostOrch = 0, grandCostEst = 0;
|
|
6243
|
+
let grandCost = 0, grandCostOrch = 0, grandCostEst = 0, grandCostSub = 0;
|
|
5684
6244
|
if (req.user && req.user.admin) {
|
|
5685
6245
|
const costSrcParts = [
|
|
5686
6246
|
"SELECT claude_session_id FROM posts WHERE claude_session_id IS NOT NULL AND posted_at IS NOT NULL",
|
|
@@ -5694,24 +6254,24 @@ async function handleApi(req, res) {
|
|
|
5694
6254
|
const costWin = "INTERVAL '" + hours + " hours'";
|
|
5695
6255
|
const costAttributed = [
|
|
5696
6256
|
"SELECT COALESCE(posts.project_name, '(none)') AS project, " +
|
|
5697
|
-
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
|
|
6257
|
+
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
|
|
5698
6258
|
"FROM posts LEFT JOIN session_cost sc ON sc.session_id = posts.claude_session_id " +
|
|
5699
6259
|
"WHERE posts.posted_at >= NOW() - " + costWin + " " +
|
|
5700
6260
|
"AND posts.our_content <> '(mention - no original post)'",
|
|
5701
6261
|
"SELECT COALESCE(replies.project_name, '(none)') AS project, " +
|
|
5702
|
-
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
|
|
6262
|
+
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
|
|
5703
6263
|
"FROM replies LEFT JOIN session_cost sc ON sc.session_id = replies.claude_session_id " +
|
|
5704
6264
|
"WHERE replies.status='replied' AND replies.replied_at >= NOW() - " + costWin,
|
|
5705
6265
|
"SELECT COALESCE(dms.target_project, '(none)') AS project, " +
|
|
5706
|
-
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
|
|
6266
|
+
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
|
|
5707
6267
|
"FROM dms LEFT JOIN session_cost sc ON sc.session_id = dms.claude_session_id " +
|
|
5708
6268
|
"WHERE dms.status='sent' AND dms.sent_at >= NOW() - " + costWin,
|
|
5709
6269
|
"SELECT COALESCE(seo_keywords.product, '(none)') AS project, " +
|
|
5710
|
-
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
|
|
6270
|
+
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
|
|
5711
6271
|
"FROM seo_keywords LEFT JOIN session_cost sc ON sc.session_id = seo_keywords.claude_session_id " +
|
|
5712
6272
|
"WHERE seo_keywords.completed_at >= NOW() - " + costWin + " AND seo_keywords.page_url IS NOT NULL",
|
|
5713
6273
|
"SELECT COALESCE(gsc_queries.product, '(none)') AS project, " +
|
|
5714
|
-
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated " +
|
|
6274
|
+
"sc.per_row_cost, sc.per_row_cost_orchestrator, sc.per_row_cost_estimated, sc.per_row_cost_subagent " +
|
|
5715
6275
|
"FROM gsc_queries LEFT JOIN session_cost sc ON sc.session_id = gsc_queries.claude_session_id " +
|
|
5716
6276
|
"WHERE gsc_queries.completed_at >= NOW() - " + costWin + " AND gsc_queries.page_url IS NOT NULL",
|
|
5717
6277
|
];
|
|
@@ -5719,15 +6279,17 @@ async function handleApi(req, res) {
|
|
|
5719
6279
|
"WITH src AS (" + costSrcParts.join(' UNION ALL ') + "), " +
|
|
5720
6280
|
"session_counts AS (SELECT claude_session_id, COUNT(*)::int AS rows_in_session FROM src GROUP BY claude_session_id), " +
|
|
5721
6281
|
"session_cost AS (SELECT cs.session_id, " +
|
|
5722
|
-
"(
|
|
6282
|
+
"(cs.orchestrator_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost, " +
|
|
5723
6283
|
"(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 " +
|
|
6284
|
+
"(cs.total_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_estimated, " +
|
|
6285
|
+
"(cs.subagent_cost_usd / NULLIF(sc.rows_in_session, 0))::numeric(12,6) AS per_row_cost_subagent " +
|
|
5725
6286
|
"FROM claude_sessions cs JOIN session_counts sc ON sc.claude_session_id = cs.session_id), " +
|
|
5726
6287
|
"attributed AS (" + costAttributed.join(' UNION ALL ') + ") " +
|
|
5727
6288
|
"SELECT project, " +
|
|
5728
6289
|
"COALESCE(SUM(per_row_cost), 0)::numeric(12,4) AS cost_usd, " +
|
|
5729
6290
|
"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 " +
|
|
6291
|
+
"COALESCE(SUM(per_row_cost_estimated), 0)::numeric(12,4) AS cost_usd_estimated, " +
|
|
6292
|
+
"COALESCE(SUM(per_row_cost_subagent), 0)::numeric(12,4) AS cost_usd_subagent " +
|
|
5731
6293
|
"FROM attributed GROUP BY project";
|
|
5732
6294
|
try {
|
|
5733
6295
|
const costRows = await pq(costQ) || [];
|
|
@@ -5736,10 +6298,12 @@ async function handleApi(req, res) {
|
|
|
5736
6298
|
const c = Number(r.cost_usd) || 0;
|
|
5737
6299
|
const co = Number(r.cost_usd_orchestrator) || 0;
|
|
5738
6300
|
const ce = Number(r.cost_usd_estimated) || 0;
|
|
5739
|
-
|
|
6301
|
+
const cs = Number(r.cost_usd_subagent) || 0;
|
|
6302
|
+
costByProject[proj] = { cost_usd: c, cost_usd_orchestrator: co, cost_usd_estimated: ce, cost_usd_subagent: cs };
|
|
5740
6303
|
grandCost += c;
|
|
5741
6304
|
grandCostOrch += co;
|
|
5742
6305
|
grandCostEst += ce;
|
|
6306
|
+
grandCostSub += cs;
|
|
5743
6307
|
});
|
|
5744
6308
|
} catch (e) {
|
|
5745
6309
|
// Soft fail: log and continue without cost data. Don't block the
|
|
@@ -5753,6 +6317,7 @@ async function handleApi(req, res) {
|
|
|
5753
6317
|
r.cost_usd = c ? c.cost_usd : 0;
|
|
5754
6318
|
r.cost_usd_orchestrator = c ? c.cost_usd_orchestrator : 0;
|
|
5755
6319
|
r.cost_usd_estimated = c ? c.cost_usd_estimated : 0;
|
|
6320
|
+
r.cost_usd_subagent = c ? c.cost_usd_subagent : 0;
|
|
5756
6321
|
return r;
|
|
5757
6322
|
};
|
|
5758
6323
|
projects.forEach(attachCost);
|
|
@@ -5791,6 +6356,7 @@ async function handleApi(req, res) {
|
|
|
5791
6356
|
grand_cost_usd: grandCost,
|
|
5792
6357
|
grand_cost_usd_orchestrator: grandCostOrch,
|
|
5793
6358
|
grand_cost_usd_estimated: grandCostEst,
|
|
6359
|
+
grand_cost_usd_subagent: grandCostSub,
|
|
5794
6360
|
cost_available: !!(req.user && req.user.admin),
|
|
5795
6361
|
can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
|
|
5796
6362
|
projects,
|
|
@@ -6389,6 +6955,19 @@ const HTML = `<!DOCTYPE html>
|
|
|
6389
6955
|
#top-pages-container .style-stats-table th,
|
|
6390
6956
|
#top-pages-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; }
|
|
6391
6957
|
#top-pages-container .style-stats-table td[data-col-key="path"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
|
|
6958
|
+
|
|
6959
|
+
#top-links-container .style-stats-table { table-layout: fixed; }
|
|
6960
|
+
#top-links-container .style-stats-table th,
|
|
6961
|
+
#top-links-container .style-stats-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 10px 10px; vertical-align: top; }
|
|
6962
|
+
#top-links-container .style-stats-table td[data-col-key="target_url"] { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-all; }
|
|
6963
|
+
#top-links-container .dest-kind-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; }
|
|
6964
|
+
#top-links-container .dest-kind-home { background: rgba(16,185,129,0.18); color: #10b981; }
|
|
6965
|
+
#top-links-container .dest-kind-subpage { background: rgba(59,130,246,0.18); color: #3b82f6; }
|
|
6966
|
+
#top-links-container .dest-kind-seo { background: rgba(168,85,247,0.18); color: #a855f7; }
|
|
6967
|
+
#top-links-container .dest-kind-booking { background: rgba(245,158,11,0.18); color: #f59e0b; }
|
|
6968
|
+
#top-links-container .dest-kind-github { background: rgba(148,163,184,0.18); color: #94a3b8; }
|
|
6969
|
+
#top-links-container .dest-kind-external { background: rgba(239,68,68,0.18); color: #ef4444; }
|
|
6970
|
+
#top-links-container .dest-kind-other { background: rgba(148,163,184,0.18); color: #94a3b8; }
|
|
6392
6971
|
/* DMs sub-tab */
|
|
6393
6972
|
#top-dms-container .style-stats-table { table-layout: fixed; }
|
|
6394
6973
|
#top-dms-container .style-stats-table th,
|
|
@@ -6662,6 +7241,24 @@ const HTML = `<!DOCTYPE html>
|
|
|
6662
7241
|
.style-stats-pill:hover { border-color: var(--border-strong); background: var(--bg-hover); }
|
|
6663
7242
|
.style-stats-pill.active { background: var(--accent-panel-bg); border-color: #3b82f6; color: var(--text); }
|
|
6664
7243
|
|
|
7244
|
+
/* In-place loading state for stats containers. Dims the previously-rendered
|
|
7245
|
+
grid/table so it stays visible (no layout jump) but reads clearly as stale.
|
|
7246
|
+
Used by loadActivityStats / loadCohortStats / loadStyleStats while their
|
|
7247
|
+
fetches are in flight. */
|
|
7248
|
+
.is-loading { opacity: 0.5; transition: opacity 0.12s linear; pointer-events: none; }
|
|
7249
|
+
|
|
7250
|
+
/* Disable Stats-tab filter pills while any stats fetch is in flight
|
|
7251
|
+
(body.sa-stats-busy) so users can't queue overlapping requests across
|
|
7252
|
+
rapid pill changes. Covers the three pill rows that drive
|
|
7253
|
+
reloadStatsTabSections: stats window + style-stats platform/project. */
|
|
7254
|
+
body.sa-stats-busy #stats-window-pills .style-stats-pill,
|
|
7255
|
+
body.sa-stats-busy #style-stats-platform-pills .style-stats-pill,
|
|
7256
|
+
body.sa-stats-busy #style-stats-project-pills .style-stats-pill {
|
|
7257
|
+
pointer-events: none;
|
|
7258
|
+
opacity: 0.55;
|
|
7259
|
+
cursor: wait;
|
|
7260
|
+
}
|
|
7261
|
+
|
|
6665
7262
|
@media (max-width: 600px) { .cards { grid-template-columns: 1fr; } .content { padding: 16px; } }
|
|
6666
7263
|
|
|
6667
7264
|
/* Client-mode auth overlay. Non-admin users see the app with admin-only
|
|
@@ -7096,6 +7693,11 @@ const HTML = `<!DOCTYPE html>
|
|
|
7096
7693
|
<span class="top-subtab-label">DMs</span>
|
|
7097
7694
|
<span class="top-subtab-sub">prospect chats</span>
|
|
7098
7695
|
</span>
|
|
7696
|
+
<span class="top-subtab" data-subtab="links" role="tab" aria-selected="false" title="Destination URLs across all posts, ranked by clicks. Homepage vs audience pages vs SEO pages.">
|
|
7697
|
+
<span class="top-subtab-icon" aria-hidden="true">\ud83d\udd17</span>
|
|
7698
|
+
<span class="top-subtab-label">Links</span>
|
|
7699
|
+
<span class="top-subtab-sub">destinations</span>
|
|
7700
|
+
</span>
|
|
7099
7701
|
</div>
|
|
7100
7702
|
<div class="top-controls">
|
|
7101
7703
|
<input id="top-search" class="top-search" type="search" placeholder="Search posts\u2026" />
|
|
@@ -7197,6 +7799,9 @@ const HTML = `<!DOCTYPE html>
|
|
|
7197
7799
|
<div id="top-dms-container" class="hidden">
|
|
7198
7800
|
<div class="style-stats-empty">Loading\u2026</div>
|
|
7199
7801
|
</div>
|
|
7802
|
+
<div id="top-links-container" class="hidden">
|
|
7803
|
+
<div class="style-stats-empty">Loading\u2026</div>
|
|
7804
|
+
</div>
|
|
7200
7805
|
</div>
|
|
7201
7806
|
|
|
7202
7807
|
<div class="content hidden" id="tab-logs">
|
|
@@ -7855,31 +8460,53 @@ function renderResult(run) {
|
|
|
7855
8460
|
'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
|
|
7856
8461
|
salvBracket +
|
|
7857
8462
|
'</span>';
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
'
|
|
7865
|
-
'
|
|
7866
|
-
'
|
|
7867
|
-
|
|
7868
|
-
' (
|
|
7869
|
-
'
|
|
7870
|
-
'
|
|
7871
|
-
|
|
7872
|
-
|
|
8463
|
+
// Tooltip is grouped by cycle phase so the funnel reads chronologically.
|
|
8464
|
+
// String.fromCharCode(10) sidesteps the outer HTML backtick template
|
|
8465
|
+
// that strips literal backslash-n escapes (see feedback_server_js_template_regex).
|
|
8466
|
+
// CSS .sa-tooltip white-space:pre-line turns these into line breaks.
|
|
8467
|
+
const NL = String.fromCharCode(10);
|
|
8468
|
+
const tooltip =
|
|
8469
|
+
'Phase 0 (cleanup):' + NL +
|
|
8470
|
+
'• salvaged ' + salvAttempted + ': orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
|
|
8471
|
+
'• pool for next cycle: ' + salvageableLive + ' salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
|
|
8472
|
+
NL +
|
|
8473
|
+
'Phase 1 (scrape):' + NL +
|
|
8474
|
+
'• searches ' + searches + ': queries run' + NL +
|
|
8475
|
+
'• raw ' + raw + ': tweets returned' + NL +
|
|
8476
|
+
'• passed ' + passed + ': after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
|
|
8477
|
+
NL +
|
|
8478
|
+
'Phase 2a (Δ re-score):' + NL +
|
|
8479
|
+
'• expired ' + expired + ': below Δ<1 likes floor' + NL +
|
|
8480
|
+
NL +
|
|
8481
|
+
'Phase 2b (draft + post):' + NL +
|
|
8482
|
+
'• Δ≥10 ' + aboveFloor + ': crossed POST_LIMIT=3 review cap' + NL +
|
|
8483
|
+
'• posted ' + posted + ': shipped' + NL +
|
|
8484
|
+
'• failed ' + failed + ': post errors' + NL +
|
|
8485
|
+
NL +
|
|
8486
|
+
'Pending end-of-run: ' + queue +
|
|
8487
|
+
' (start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
|
|
8488
|
+
qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped)' + NL +
|
|
8489
|
+
'Pending live: ' + pendingLive;
|
|
8490
|
+
// Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
|
|
8491
|
+
// then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
|
|
8492
|
+
// Phase 2b decision and outcome (Δ≥10, posted, failed).
|
|
7873
8493
|
return (
|
|
7874
8494
|
'<span title="' + tooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
8495
|
+
queuePill +
|
|
7875
8496
|
pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
7876
8497
|
pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
7877
8498
|
pill('passed', passed, passed > 0 ? '#22c55e' : 'var(--muted)') +
|
|
7878
8499
|
pill('expired', expired, expired > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
7879
8500
|
pill('Δ≥10', aboveFloor, aboveFloor > 0 ? '#a78bfa' : 'var(--muted)') +
|
|
7880
8501
|
pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
|
|
7881
|
-
queuePill +
|
|
7882
8502
|
renderFailedPill() +
|
|
8503
|
+
(Array.isArray(r.projects_worked) && r.projects_worked.length
|
|
8504
|
+
? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
8505
|
+
+ 'projects '
|
|
8506
|
+
+ '<span style="color:var(--text);font-weight:600;">'
|
|
8507
|
+
+ r.projects_worked.join(', ')
|
|
8508
|
+
+ '</span></span>'
|
|
8509
|
+
: '') +
|
|
7883
8510
|
'</span>'
|
|
7884
8511
|
);
|
|
7885
8512
|
}
|
|
@@ -8153,6 +8780,26 @@ function renderResult(run) {
|
|
|
8153
8780
|
// the old "posted=18216" pill was the total active-posts count from the
|
|
8154
8781
|
// DB, which had nothing to do with what the run did. Render the real
|
|
8155
8782
|
// per-run counters parsed out of the stats log instead.
|
|
8783
|
+
//
|
|
8784
|
+
// 2026-05-18 relabel: split the misleading single "updated" pill into
|
|
8785
|
+
// four explicit pills (scanned / checked / changed / views) so the
|
|
8786
|
+
// operator can read at a glance what the run actually did:
|
|
8787
|
+
//
|
|
8788
|
+
// scanned -> total rows considered (= polled + every flavor of skip)
|
|
8789
|
+
// skipped -> rows we deliberately did NOT poll (covered by Step 1
|
|
8790
|
+
// scrape or stable cooldown). Saves API calls.
|
|
8791
|
+
// checked -> rows we actually hit the platform API for this run.
|
|
8792
|
+
// changed -> subset of "checked" where any tracked metric moved.
|
|
8793
|
+
// Was the original intent of "updated" but the legacy
|
|
8794
|
+
// field also summed in the Step 1 view-scrape count.
|
|
8795
|
+
// views -> Step 1 scrape leg only: rows where the cheap profile-
|
|
8796
|
+
// page scrape wrote a fresh view count (Reddit) or
|
|
8797
|
+
// fxtwitter view (Twitter). Distinct from "changed".
|
|
8798
|
+
// replies -> per-reply rows refreshed (DM-rail follow-ups we made
|
|
8799
|
+
// on someone else thread, live in the "replies" table).
|
|
8800
|
+
//
|
|
8801
|
+
// Each pill carries data-tooltip so hovering surfaces the meaning
|
|
8802
|
+
// line-by-line via the global .sa-tooltip handler.
|
|
8156
8803
|
if (run.job_type === 'stats') {
|
|
8157
8804
|
const checked = r.checked || 0;
|
|
8158
8805
|
const updated = r.updated || 0;
|
|
@@ -8162,19 +8809,71 @@ function renderResult(run) {
|
|
|
8162
8809
|
const skipped = r.skipped || 0;
|
|
8163
8810
|
const failed = r.failed || 0;
|
|
8164
8811
|
const repliesRefreshed = r.replies_refreshed || 0;
|
|
8812
|
+
// New 2026-05-18 fields; fall back to derived values when the log line
|
|
8813
|
+
// pre-dates the relabel pass (so historical rows still render sanely).
|
|
8814
|
+
const scanned = r.scanned || (checked + skipped) || 0;
|
|
8815
|
+
const changed = r.changed || 0;
|
|
8816
|
+
const viewsRefreshed = r.views_refreshed || 0;
|
|
8165
8817
|
if (!checked && !updated && !removed && !unavailable && !notFound &&
|
|
8166
|
-
!skipped && !failed && !repliesRefreshed
|
|
8818
|
+
!skipped && !failed && !repliesRefreshed &&
|
|
8819
|
+
!scanned && !changed && !viewsRefreshed) {
|
|
8167
8820
|
return '<span style="color:var(--muted);font-size:12px;">—</span>';
|
|
8168
8821
|
}
|
|
8822
|
+
// Inline helper: pill with a data-tooltip attribute for per-pill hover
|
|
8823
|
+
// explanations. Plain pill() (above) has no tooltip slot; this is
|
|
8824
|
+
// local to stats-job rendering only.
|
|
8825
|
+
const tipPill = (label, n, color, tip) => {
|
|
8826
|
+
const tipEsc = (tip || '').replace(/"/g, '"');
|
|
8827
|
+
return '<span data-tooltip="' + tipEsc + '" style="display:inline-block;' +
|
|
8828
|
+
'margin-right:10px;font-size:12px;color:var(--muted);cursor:help;">' +
|
|
8829
|
+
label + ' <span style="color:' + (color || 'var(--text)') +
|
|
8830
|
+
';font-weight:600;">' + n + '</span></span>';
|
|
8831
|
+
};
|
|
8169
8832
|
return (
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
(
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
8833
|
+
tipPill('scanned', scanned, 'var(--text)',
|
|
8834
|
+
'Total rows the run considered (every active row in the relevant ' +
|
|
8835
|
+
'platform tables). = checked + skipped + bypassed-as-fresh.') +
|
|
8836
|
+
(skipped ? tipPill('skipped', skipped, 'var(--muted)',
|
|
8837
|
+
'Rows we deliberately did NOT poll this run. Two reasons: ' +
|
|
8838
|
+
'(1) already refreshed by the cheap scrape leg within the last 4h, ' +
|
|
8839
|
+
'(2) stable cooldown (2+ scans with no metric change AND older than ' +
|
|
8840
|
+
'3 days). Saves API calls; data is still current.') : '') +
|
|
8841
|
+
tipPill('checked', checked, 'var(--text)',
|
|
8842
|
+
'Rows we actually hit the platform API for this run ' +
|
|
8843
|
+
'(Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed). ' +
|
|
8844
|
+
'Includes both successful polls and the ones that errored mid-fetch.') +
|
|
8845
|
+
tipPill('changed', changed, '#22c55e',
|
|
8846
|
+
'Subset of CHECKED where any tracked metric (upvotes, ' +
|
|
8847
|
+
'comments_count, views) actually moved since the prior scan. ' +
|
|
8848
|
+
'The real-activity signal; everything else is no-op polling.') +
|
|
8849
|
+
(viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
|
|
8850
|
+
'Rows where the cheap view-scrape leg wrote a fresh view count ' +
|
|
8851
|
+
'this run. Reddit: Step 1 profile-page scrape (sees every ' +
|
|
8852
|
+
'comment + thread on /user/<name>/). Twitter: built-in to the ' +
|
|
8853
|
+
'fxtwitter call. Separate from CHANGED because views can ' +
|
|
8854
|
+
'tick up without upvotes/comments moving.') : '') +
|
|
8855
|
+
(repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
|
|
8856
|
+
'Per-reply rows refreshed: comments we authored AS replies to ' +
|
|
8857
|
+
'someone else reply to our original comment (the DM-rail ' +
|
|
8858
|
+
'follow-up). Live in the replies table, not posts. ' +
|
|
8859
|
+
'Reddit refreshes upvotes + reply-count via batch JSON API. ' +
|
|
8860
|
+
'Twitter also refreshes views via fxtwitter.') : '') +
|
|
8861
|
+
(removed ? tipPill('removed', removed, '#eab308',
|
|
8862
|
+
'Posts newly flagged deleted/removed this run. Reddit: comment ' +
|
|
8863
|
+
'gone from thread JSON for 2+ consecutive scans (deletion_detect_' +
|
|
8864
|
+
'count threshold). LinkedIn: post returned "Post unavailable".') : '') +
|
|
8865
|
+
(unavailable ? tipPill('unavail', unavailable, '#eab308',
|
|
8866
|
+
'LinkedIn only: post explicitly returned a Post Unavailable ' +
|
|
8867
|
+
'string. Subset of REMOVED; rendered as its own pill so an ' +
|
|
8868
|
+
'operator can tell hard-deletion from rate-limit or network noise.') : '') +
|
|
8869
|
+
(notFound ? tipPill('not found', notFound, 'var(--muted)',
|
|
8870
|
+
'LinkedIn only: post is still active on LinkedIn but our specific ' +
|
|
8871
|
+
'comment couldn\'t be located on the activity feed (may have ' +
|
|
8872
|
+
'aged off our visible recent-activity window).') : '') +
|
|
8873
|
+
(failed ? tipPill('failed', failed, '#ef4444',
|
|
8874
|
+
'API errors during the run, broken down by category: 404 not_found, ' +
|
|
8875
|
+
'rate-limited (429), empty / malformed response, or other / network. ' +
|
|
8876
|
+
'Includes step-exit failures from the shell pipeline as well.') : '')
|
|
8178
8877
|
);
|
|
8179
8878
|
}
|
|
8180
8879
|
// seo_expire (delete dead-weight pages): repurposes posted/skipped from the
|
|
@@ -8332,7 +9031,7 @@ function buildSeoDetailRows(run) {
|
|
|
8332
9031
|
if (!details.length) return '';
|
|
8333
9032
|
const subRows = details.map(d => {
|
|
8334
9033
|
const cost = (typeof d.cost_usd === 'number' && d.cost_usd > 0)
|
|
8335
|
-
? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated)
|
|
9034
|
+
? fmtCostCell(d.cost_usd, d.cost_usd_orchestrator, d.cost_usd_estimated, d.cost_usd_subagent)
|
|
8336
9035
|
: '<span style="color:var(--muted);">—</span>';
|
|
8337
9036
|
const turns = (typeof d.num_turns === 'number' && d.num_turns > 0)
|
|
8338
9037
|
? d.num_turns
|
|
@@ -8378,6 +9077,85 @@ function buildSeoDetailRows(run) {
|
|
|
8378
9077
|
);
|
|
8379
9078
|
}
|
|
8380
9079
|
|
|
9080
|
+
// Cost cell for a Job History row. SDK-only mode (2026-05-15): the headline
|
|
9081
|
+
// total is the sum of orchestrator_cost_usd across the phases of this run.
|
|
9082
|
+
// Sessions whose wrapper didn't capture SDK cost contribute 0; the per-phase
|
|
9083
|
+
// breakdown surfaces a "missing SDK" count so the operator can spot pipelines
|
|
9084
|
+
// where real spend went unrecorded (the cost cell shows the smaller real
|
|
9085
|
+
// number, not an inflated estimate).
|
|
9086
|
+
function _jobHistoryCostCell(result) {
|
|
9087
|
+
const fmtLane = (v) => {
|
|
9088
|
+
if (v == null) return 'n/a';
|
|
9089
|
+
const n = Number(v);
|
|
9090
|
+
if (!isFinite(n)) return 'n/a';
|
|
9091
|
+
if (n === 0) return '$0';
|
|
9092
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
9093
|
+
return '$' + n.toFixed(4);
|
|
9094
|
+
};
|
|
9095
|
+
const bd = result.cost_breakdown;
|
|
9096
|
+
const orch = result.cost_usd_orchestrator != null ? Number(result.cost_usd_orchestrator) : null;
|
|
9097
|
+
const sub = result.cost_usd_subagent != null ? Number(result.cost_usd_subagent) : null;
|
|
9098
|
+
const est = result.cost_usd_estimated != null ? Number(result.cost_usd_estimated) : null;
|
|
9099
|
+
const sessionsAll = bd ? Number(bd.sessions) || 0 : 0;
|
|
9100
|
+
const sessionsWithSdk = bd ? Number(bd.sessions_with_sdk) || 0 : 0;
|
|
9101
|
+
const sessionsMissing = Math.max(0, sessionsAll - sessionsWithSdk);
|
|
9102
|
+
const totalForDisplay = orch != null ? orch : 0;
|
|
9103
|
+
// Header value: "n/a" when no SDK data captured for any of the run's
|
|
9104
|
+
// sessions, otherwise the orchestrator sum (with a "(N missing SDK)"
|
|
9105
|
+
// hint inline when partial).
|
|
9106
|
+
let headerHtml;
|
|
9107
|
+
if (sessionsAll === 0) {
|
|
9108
|
+
headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
|
|
9109
|
+
} else if (sessionsWithSdk === 0) {
|
|
9110
|
+
headerHtml = '<span style="color:var(--text-very-faint);">n/a</span>';
|
|
9111
|
+
} else if (sessionsMissing > 0) {
|
|
9112
|
+
headerHtml = fmtCost(totalForDisplay) +
|
|
9113
|
+
' <span style="color:#eab308;font-size:11px;">(' + sessionsMissing + ' missing)</span>';
|
|
9114
|
+
} else {
|
|
9115
|
+
headerHtml = fmtCost(totalForDisplay);
|
|
9116
|
+
}
|
|
9117
|
+
const lines = [
|
|
9118
|
+
'Cost (SDK orchestrator): ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
|
|
9119
|
+
];
|
|
9120
|
+
if (sessionsAll > 0) {
|
|
9121
|
+
lines.push(' Sessions: ' + sessionsAll +
|
|
9122
|
+
' · with SDK data: ' + sessionsWithSdk +
|
|
9123
|
+
' · missing SDK: ' + sessionsMissing);
|
|
9124
|
+
}
|
|
9125
|
+
if (bd && Array.isArray(bd.phases) && bd.phases.length) {
|
|
9126
|
+
lines.push('');
|
|
9127
|
+
lines.push('Per-phase (claude_sessions.script grouping):');
|
|
9128
|
+
const shown = bd.phases.slice(0, 10);
|
|
9129
|
+
for (const p of shown) {
|
|
9130
|
+
const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
|
|
9131
|
+
? (' [' + p.sessions_missing_sdk + ' missing SDK]')
|
|
9132
|
+
: '';
|
|
9133
|
+
const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
|
|
9134
|
+
? fmtLane(p.orch)
|
|
9135
|
+
: 'n/a';
|
|
9136
|
+
lines.push(' ' + (p.phase || '(unknown)') + ' x' + p.sessions +
|
|
9137
|
+
' ' + orchVal + missing);
|
|
9138
|
+
}
|
|
9139
|
+
if (bd.phases.length > shown.length) {
|
|
9140
|
+
lines.push(' …(' + (bd.phases.length - shown.length) + ' more)');
|
|
9141
|
+
}
|
|
9142
|
+
}
|
|
9143
|
+
if (typeof result.cost_usd_from_log === 'number') {
|
|
9144
|
+
lines.push('');
|
|
9145
|
+
lines.push('Wrapper shell-log value: ' + fmtLane(result.cost_usd_from_log));
|
|
9146
|
+
}
|
|
9147
|
+
lines.push('');
|
|
9148
|
+
lines.push('Diagnostic-only (local pricing estimate, not actual billing):');
|
|
9149
|
+
lines.push(' Transcript estimate: ' + fmtLane(est));
|
|
9150
|
+
lines.push(' Subagent (est): ' + fmtLane(sub));
|
|
9151
|
+
lines.push('');
|
|
9152
|
+
lines.push('SDK-only mode: shows orchestrator_cost_usd captured by the SDK result event. "missing SDK" = wrapper script didn\\'t pass --output-format json to claude, so no result event = no cost data recorded. Patch the wrapper to fix.');
|
|
9153
|
+
const tip = lines.join('\\n');
|
|
9154
|
+
return '<span data-tooltip="' + escapeHtml(tip) +
|
|
9155
|
+
'" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
|
|
9156
|
+
headerHtml + '</span>';
|
|
9157
|
+
}
|
|
9158
|
+
|
|
8381
9159
|
// Stable identity for a job-history row across polls. (script, started_at)
|
|
8382
9160
|
// is unique in practice; pid is appended as a tiebreaker for the rare case
|
|
8383
9161
|
// where two parallel fires of the same script land in the same second.
|
|
@@ -8406,8 +9184,16 @@ function _jobHistoryRowSig(r) {
|
|
|
8406
9184
|
].join('|');
|
|
8407
9185
|
}
|
|
8408
9186
|
function _buildJobHistoryRowGroup(r, idx) {
|
|
8409
|
-
|
|
8410
|
-
|
|
9187
|
+
// SDK-only mode: render the cost cell whenever the run produced any
|
|
9188
|
+
// claude_sessions rows (cost_breakdown is attached), even if the SDK lane
|
|
9189
|
+
// is NULL across all of them — the tooltip surfaces "missing SDK" so the
|
|
9190
|
+
// operator sees pipelines whose wrappers haven't been patched yet.
|
|
9191
|
+
const hasBreakdown = r.result && r.result.cost_breakdown
|
|
9192
|
+
&& Number(r.result.cost_breakdown.sessions) > 0;
|
|
9193
|
+
const hasLogCost = r.result && typeof r.result.cost_usd === 'number';
|
|
9194
|
+
const costCell = (hasBreakdown || hasLogCost)
|
|
9195
|
+
? _jobHistoryCostCell(r.result)
|
|
9196
|
+
: '<span style="color:var(--muted);">—</span>';
|
|
8411
9197
|
const hasDetails = Array.isArray(r.details) && r.details.length;
|
|
8412
9198
|
const caret = hasDetails
|
|
8413
9199
|
? '<span class="sa-job-caret" style="display:inline-block;width:12px;color:var(--muted);cursor:pointer;user-select:none;transition:transform 0.15s ease;">▸</span> '
|
|
@@ -8972,8 +9758,12 @@ async function saveSettings() {
|
|
|
8972
9758
|
}
|
|
8973
9759
|
|
|
8974
9760
|
// Activity tab
|
|
8975
|
-
|
|
8976
|
-
|
|
9761
|
+
// Page-generation event types. Folded into a single 'pages_generated' card
|
|
9762
|
+
// in renderActivityStats (with per-pipeline breakdown in the body + tooltip).
|
|
9763
|
+
// SQL still emits each subtype so the breakdown is faithful.
|
|
9764
|
+
const PAGE_GEN_EVENT_TYPES = ['page_published_twitter', 'page_published_gsc', 'page_published_reddit', 'page_published_top', 'page_published_top_post', 'page_published_roundup', 'page_published_misc', 'page_improved'];
|
|
9765
|
+
const EVENT_TYPES = ['posted_thread', 'posted_comment', 'replied', 'skipped', 'mention', 'dm_sent', 'dm_reply_sent', ...PAGE_GEN_EVENT_TYPES, 'page_expired', 'resurrected'];
|
|
9766
|
+
const EVENT_LABELS = { posted_thread: 'thread posted', posted_comment: 'comment posted', replied: 'engage replied', skipped: 'engage skipped', mention: 'mention', dm_sent: 'dm sent', dm_reply_sent: 'dm reply', page_published_twitter: 'page (twitter)', page_published_gsc: 'page (gsc)', page_published_reddit: 'page (reddit)', page_published_top: 'page (top)', page_published_top_post: 'page (top post)', page_published_roundup: 'page (roundup)', page_published_misc: 'page (misc)', page_improved: 'page (improved)', page_expired: 'page expired', resurrected: 'resurrected', pages_generated: 'pages generated' };
|
|
8977
9767
|
const EVENT_DESCRIPTIONS = {
|
|
8978
9768
|
posted_thread: 'Original thread the bot published (Post Threads job): a new top-level Reddit submission, tweet/X post, LinkedIn post, etc. Identified by thread_url = our_url AND thread_author is empty or matches our_account.',
|
|
8979
9769
|
posted_comment: 'Comment the bot left on someone else’s thread (Post Comments job): a Reddit/Twitter/LinkedIn/Moltbook/GitHub reply where thread_url ≠ our_url, or where thread_url = our_url but thread_author is someone else (LinkedIn comment job stores the parent URL in both columns).',
|
|
@@ -8982,13 +9772,15 @@ const EVENT_DESCRIPTIONS = {
|
|
|
8982
9772
|
mention: 'Someone mentioned one of our products on a tracked platform. Detection only, no engagement action.',
|
|
8983
9773
|
dm_sent: 'New direct-message conversation the bot started with a prospect.',
|
|
8984
9774
|
dm_reply_sent: 'Follow-up message sent inside an existing DM conversation.',
|
|
8985
|
-
|
|
9775
|
+
pages_generated: 'Total SEO/landing pages produced across every pipeline in this window. Hover the breakdown chips for the per-pipeline counts. The standalone SERP pipeline was unloaded 2026-04-17; any page tagged "twitter" came from the Twitter cycle page-gen A/B lane (twitter_gen_links.py), not a dedicated SERP run.',
|
|
9776
|
+
page_published_twitter: 'SEO landing page generated by the Twitter cycle page-gen A/B lane (twitter_gen_links.py -> generate_page.py --trigger twitter). Source rows have seo_keywords.source=twitter. This is what the legacy "SERP SEO" pill was actually counting after the standalone SERP pipeline was unloaded on 2026-04-17.',
|
|
8986
9777
|
page_published_gsc: 'SEO page generated from a Google Search Console query the site already gets impressions for.',
|
|
8987
9778
|
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.',
|
|
9779
|
+
page_published_top: 'SEO page generated for a top-of-funnel ranking opportunity (top_pages pipeline).',
|
|
8989
9780
|
page_published_top_post: 'SEO page generated retroactively for a viral social-autoposter post (>=10k views, last 7d) whose link still points at the homepage. Pipeline also mounts a NewsStrip on the homepage routing existing organic clickthroughs to the new /t/ page.',
|
|
8990
9781
|
page_published_roundup: 'Roundup or list-style SEO page (comparisons, best-of, alternatives).',
|
|
8991
|
-
|
|
9782
|
+
page_published_misc: 'SEO page from a legacy or edge source (existing_page, suggestion:*, competitor:*, topic_template, feature_template, client_blog, etc). Mostly historical; near-zero new volume in 2026-05.',
|
|
9783
|
+
page_improved: 'Existing SEO page that was updated or rewritten to improve rankings (seo_page_improvements table).',
|
|
8992
9784
|
page_expired: 'SEO page deleted by the daily expire pipeline because it had zero clicks in the last 30 days. The on-disk source file was removed; Next.js now returns 404 for the URL. Logged for audit/revert in seo_expired_pages.',
|
|
8993
9785
|
resurrected: 'Previously archived or unavailable item brought back into rotation (e.g., a removed post restored after reappearing).',
|
|
8994
9786
|
};
|
|
@@ -9000,7 +9792,10 @@ const ACTIVITY_CAMPAIGN_ORGANIC = '(organic)';
|
|
|
9000
9792
|
let _activitySeen = new Set();
|
|
9001
9793
|
let _activityFirstLoad = true;
|
|
9002
9794
|
// Activity-tab filters/sort/search are persisted across reloads.
|
|
9003
|
-
|
|
9795
|
+
// Bumped v2 -> v3 on 2026-05-16 when page_published_serp was split into
|
|
9796
|
+
// page_published_twitter + page_published_misc; old saved Sets did not
|
|
9797
|
+
// include the new types, so they'd silently disappear from the feed.
|
|
9798
|
+
let _activityTypeFilter = saLoadSet('sa.activity.typeFilter.v3', EVENT_TYPES);
|
|
9004
9799
|
let _activityPlatformFilter = saLoadSet('sa.activity.platformFilter.v1', ACTIVITY_PLATFORMS);
|
|
9005
9800
|
let _activityProjectFilter = saLoadSet('sa.activity.projectFilter.v1', []);
|
|
9006
9801
|
let _activityKnownProjects = saLoad('sa.activity.knownProjects.v1', []);
|
|
@@ -9098,7 +9893,7 @@ function buildActivityFilters() {
|
|
|
9098
9893
|
var added;
|
|
9099
9894
|
if (_activityTypeFilter.has(t)) { _activityTypeFilter.delete(t); el.classList.remove('active'); added = false; }
|
|
9100
9895
|
else { _activityTypeFilter.add(t); el.classList.add('active'); added = true; }
|
|
9101
|
-
saSaveSet('sa.activity.typeFilter.
|
|
9896
|
+
saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
|
|
9102
9897
|
try { window.posthog && window.posthog.capture('filter_toggle', { table: 'activity', dimension: 'type', value: t, action: added ? 'add' : 'remove' }); } catch (er) {}
|
|
9103
9898
|
_activityPage = 0;
|
|
9104
9899
|
renderActivity(_lastActivityEvents || []);
|
|
@@ -9151,11 +9946,11 @@ function buildActivityFilters() {
|
|
|
9151
9946
|
if (a === 'type-all') {
|
|
9152
9947
|
_activityTypeFilter = new Set(EVENT_TYPES);
|
|
9153
9948
|
tEl.querySelectorAll('[data-type]').forEach(c => c.classList.add('active'));
|
|
9154
|
-
saSaveSet('sa.activity.typeFilter.
|
|
9949
|
+
saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
|
|
9155
9950
|
} else if (a === 'type-none') {
|
|
9156
9951
|
_activityTypeFilter = new Set();
|
|
9157
9952
|
tEl.querySelectorAll('[data-type]').forEach(c => c.classList.remove('active'));
|
|
9158
|
-
saSaveSet('sa.activity.typeFilter.
|
|
9953
|
+
saSaveSet('sa.activity.typeFilter.v3', _activityTypeFilter);
|
|
9159
9954
|
} else if (a === 'platform-all') {
|
|
9160
9955
|
_activityPlatformFilter = new Set(ACTIVITY_PLATFORMS);
|
|
9161
9956
|
pEl.querySelectorAll('[data-platform]').forEach(c => c.classList.add('active'));
|
|
@@ -9257,19 +10052,21 @@ function fmtCost(c) {
|
|
|
9257
10052
|
//
|
|
9258
10053
|
// Args (no backticks anywhere; this whole helper sits inside the dashboard
|
|
9259
10054
|
// HTML template literal, see feedback_server_js_template_regex memory):
|
|
9260
|
-
// displayed value rendered in the cell.
|
|
9261
|
-
//
|
|
10055
|
+
// displayed value rendered in the cell. Total = COALESCE(orch,
|
|
10056
|
+
// estimate) + subagent. Source of truth for the text.
|
|
9262
10057
|
// orchestrator native SDK orchestrator cost (claude_sessions.
|
|
9263
10058
|
// orchestrator_cost_usd, captured from streamRes.
|
|
9264
10059
|
// total_cost_usd). Authoritative for orchestrator billing
|
|
9265
10060
|
// but EXCLUDES Task subagent costs (anthropics/claude-code
|
|
9266
|
-
// issue #43945).
|
|
9267
|
-
// estimated manual transcript-derived estimate
|
|
9268
|
-
//
|
|
10061
|
+
// issue #43945). Subagent is now folded in via the 4th arg.
|
|
10062
|
+
// estimated manual transcript-derived estimate of orchestrator turns
|
|
10063
|
+
// only (claude_sessions.total_cost_usd, written by
|
|
9269
10064
|
// log_claude_session.py).
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
10065
|
+
// subagent Task/Agent subagent cost from sidechain transcripts +
|
|
10066
|
+
// sibling subagents/*.jsonl files (claude_sessions.
|
|
10067
|
+
// subagent_cost_usd). Added on top of the orch/estimate
|
|
10068
|
+
// lane to form the displayed total.
|
|
10069
|
+
function fmtCostCell(displayed, orchestrator, estimated, subagent) {
|
|
9273
10070
|
const fmtLane = (v) => {
|
|
9274
10071
|
if (v == null) return 'n/a';
|
|
9275
10072
|
const n = Number(v);
|
|
@@ -9278,13 +10075,23 @@ function fmtCostCell(displayed, orchestrator, estimated) {
|
|
|
9278
10075
|
if (n < 0.01) return '$' + n.toFixed(4);
|
|
9279
10076
|
return '$' + n.toFixed(4);
|
|
9280
10077
|
};
|
|
10078
|
+
// SDK-only mode (2026-05-15): the displayed value is orchestrator_cost_usd
|
|
10079
|
+
// alone. When that's NULL we render "n/a" — not $0, since the session DID
|
|
10080
|
+
// spend money but the wrapper didn't capture --orchestrator-cost-usd.
|
|
10081
|
+
// Estimate and subagent are diagnostic-only (computed from local pricing
|
|
10082
|
+
// table; not actual billing data).
|
|
10083
|
+
const hasOrch = orchestrator != null && Number.isFinite(Number(orchestrator));
|
|
10084
|
+
const text = hasOrch
|
|
10085
|
+
? fmtCost(Number(orchestrator))
|
|
10086
|
+
: '<span style="color:var(--text-very-faint);">n/a</span>';
|
|
9281
10087
|
const lines = [
|
|
9282
|
-
'
|
|
9283
|
-
'Estimated (transcript): ' + fmtLane(estimated),
|
|
10088
|
+
'Cost (SDK orchestrator): ' + fmtLane(orchestrator),
|
|
9284
10089
|
'',
|
|
9285
|
-
'
|
|
10090
|
+
'Diagnostic-only (not actual billing):',
|
|
10091
|
+
' Transcript estimate: ' + fmtLane(estimated),
|
|
10092
|
+
' Subagent (est): ' + fmtLane(subagent),
|
|
9286
10093
|
'',
|
|
9287
|
-
'
|
|
10094
|
+
'Displays Anthropic-reported orchestrator_cost_usd only. "n/a" means the wrapper didn\\'t capture the SDK cost (no --output-format json on the claude call). Transcript estimate and subagent figures are computed locally from a hardcoded pricing table — informational, not billing-accurate on subscription plans.',
|
|
9288
10095
|
];
|
|
9289
10096
|
const tip = lines.join('\\n');
|
|
9290
10097
|
return '<span data-tooltip="' + escapeHtml(tip) +
|
|
@@ -9581,22 +10388,31 @@ function currentStatsProject() {
|
|
|
9581
10388
|
const row = document.getElementById('style-stats-project-pills');
|
|
9582
10389
|
return (row && row.dataset.selected) || 'all';
|
|
9583
10390
|
}
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
10391
|
+
// Sets body.sa-stats-busy while the batch of stats fetches kicked off by a
|
|
10392
|
+
// single filter change is in flight. CSS uses the class to disable the four
|
|
10393
|
+
// pill rows (status window, stats window, style-stats platform/project) so
|
|
10394
|
+
// users can't queue overlapping reloads across rapid pill clicks.
|
|
10395
|
+
async function reloadStatsTabSections() {
|
|
10396
|
+
document.body.classList.add('sa-stats-busy');
|
|
10397
|
+
const pending = [
|
|
10398
|
+
loadActivityStats(),
|
|
10399
|
+
loadCohortStats(),
|
|
10400
|
+
loadStyleStats(),
|
|
10401
|
+
];
|
|
9588
10402
|
// daily-metrics chart now lives on its own Trends tab with its own filter
|
|
9589
10403
|
// bar; intentionally NOT reloaded on stats-tab window/platform/project
|
|
9590
10404
|
// of the filter bar.
|
|
9591
10405
|
const funnelEl = document.getElementById('funnel-stats');
|
|
9592
10406
|
if (funnelEl && funnelEl.open) {
|
|
9593
10407
|
if (_lastFunnelPayload) renderFunnelStats(_lastFunnelPayload);
|
|
9594
|
-
else loadFunnelStats(true);
|
|
10408
|
+
else pending.push(loadFunnelStats(true));
|
|
9595
10409
|
}
|
|
9596
10410
|
const dmEl = document.getElementById('dm-stats');
|
|
9597
|
-
if (dmEl && dmEl.open) loadDmStats(true);
|
|
10411
|
+
if (dmEl && dmEl.open) pending.push(loadDmStats(true));
|
|
9598
10412
|
const sqEl = document.getElementById('search-queries-stats');
|
|
9599
|
-
if (sqEl && sqEl.open) loadSearchQueriesStats(true);
|
|
10413
|
+
if (sqEl && sqEl.open) pending.push(loadSearchQueriesStats(true));
|
|
10414
|
+
try { await Promise.allSettled(pending); }
|
|
10415
|
+
finally { document.body.classList.remove('sa-stats-busy'); }
|
|
9600
10416
|
}
|
|
9601
10417
|
function syncStatsHeadings() {
|
|
9602
10418
|
const win = currentStatsWindow();
|
|
@@ -9641,7 +10457,63 @@ function renderActivityStats(payload) {
|
|
|
9641
10457
|
grandTotal += n;
|
|
9642
10458
|
});
|
|
9643
10459
|
if (totalEl) totalEl.textContent = grandTotal + ' events in ' + currentStatsWindow().labelLong;
|
|
9644
|
-
|
|
10460
|
+
// Fold all page-generation subtypes into a single synthetic 'pages_generated'
|
|
10461
|
+
// card so the grid stops looking like six near-empty SEO cells. The breakdown
|
|
10462
|
+
// row inside the card lists each pipeline + count chip; the hover tooltip on
|
|
10463
|
+
// each chip shows the long description for that pipeline. Card-level "i"
|
|
10464
|
+
// icon shows the umbrella tooltip.
|
|
10465
|
+
const pagesBucket = { total: 0, subtypes: {} };
|
|
10466
|
+
PAGE_GEN_EVENT_TYPES.forEach(t => {
|
|
10467
|
+
const b = byType[t];
|
|
10468
|
+
if (!b) return;
|
|
10469
|
+
pagesBucket.total += b.total;
|
|
10470
|
+
if (b.total > 0) pagesBucket.subtypes[t] = b.total;
|
|
10471
|
+
});
|
|
10472
|
+
const PAGE_GEN_CHIP_LABELS = {
|
|
10473
|
+
page_published_twitter: 'twitter',
|
|
10474
|
+
page_published_gsc: 'gsc',
|
|
10475
|
+
page_published_reddit: 'reddit',
|
|
10476
|
+
page_published_top: 'top',
|
|
10477
|
+
page_published_top_post: 'top post',
|
|
10478
|
+
page_published_roundup: 'roundup',
|
|
10479
|
+
page_published_misc: 'misc',
|
|
10480
|
+
page_improved: 'improved',
|
|
10481
|
+
};
|
|
10482
|
+
// Render order: keep PAGE_GEN_EVENT_TYPES order, then drop the non-page tail
|
|
10483
|
+
// cards (page_expired, resurrected) at the end. Suppress individual page_*
|
|
10484
|
+
// cards since we render the umbrella card instead.
|
|
10485
|
+
const renderOrder = [];
|
|
10486
|
+
EVENT_TYPES.forEach(t => {
|
|
10487
|
+
if (PAGE_GEN_EVENT_TYPES.includes(t)) return; // folded into umbrella
|
|
10488
|
+
renderOrder.push(t);
|
|
10489
|
+
});
|
|
10490
|
+
// Insert the umbrella where the first page card used to live (after dm_reply_sent).
|
|
10491
|
+
const dmIdx = renderOrder.indexOf('dm_reply_sent');
|
|
10492
|
+
if (dmIdx >= 0) renderOrder.splice(dmIdx + 1, 0, '__pages_generated__');
|
|
10493
|
+
else renderOrder.push('__pages_generated__');
|
|
10494
|
+
|
|
10495
|
+
grid.innerHTML = renderOrder.map(t => {
|
|
10496
|
+
if (t === '__pages_generated__') {
|
|
10497
|
+
const total = pagesBucket.total;
|
|
10498
|
+
const subtypes = Object.keys(pagesBucket.subtypes).sort((a, b) => pagesBucket.subtypes[b] - pagesBucket.subtypes[a]);
|
|
10499
|
+
const chips = subtypes.length
|
|
10500
|
+
? subtypes.map(st => {
|
|
10501
|
+
const lab = PAGE_GEN_CHIP_LABELS[st] || st.replace(/^page_published_/, '');
|
|
10502
|
+
const desc = EVENT_DESCRIPTIONS[st] || '';
|
|
10503
|
+
const titleAttr = desc ? ' data-tooltip="' + escapeHtml(desc) + '"' : '';
|
|
10504
|
+
return '<span class="stat-plat"' + titleAttr + '><span class="stat-plat-text">' + escapeHtml(lab) + '</span><span class="stat-plat-count">' + pagesBucket.subtypes[st] + '</span></span>';
|
|
10505
|
+
}).join('')
|
|
10506
|
+
: '<span style="color:var(--text-very-faint);">\u2014</span>';
|
|
10507
|
+
const umbrellaDesc = EVENT_DESCRIPTIONS.pages_generated || '';
|
|
10508
|
+
const infoIcon = '<span class="stat-card-info" data-tooltip="' + escapeHtml(umbrellaDesc) + '" aria-label="' + escapeHtml(umbrellaDesc) + '">i</span>';
|
|
10509
|
+
return '<div class="stat-card ev-pages-generated' + (total === 0 ? ' zero' : '') + '">' +
|
|
10510
|
+
'<div class="stat-card-head">' +
|
|
10511
|
+
'<span class="stat-card-label">pages generated' + infoIcon + '</span>' +
|
|
10512
|
+
'<span class="stat-card-count">' + total + '</span>' +
|
|
10513
|
+
'</div>' +
|
|
10514
|
+
'<div class="stat-card-breakdown">' + chips + '</div>' +
|
|
10515
|
+
'</div>';
|
|
10516
|
+
}
|
|
9645
10517
|
const bucket = byType[t];
|
|
9646
10518
|
const total = bucket.total;
|
|
9647
10519
|
const plats = Object.keys(bucket.platforms).sort((a, b) => bucket.platforms[b] - bucket.platforms[a]);
|
|
@@ -9668,6 +10540,13 @@ function renderActivityStats(payload) {
|
|
|
9668
10540
|
}
|
|
9669
10541
|
|
|
9670
10542
|
async function loadActivityStats() {
|
|
10543
|
+
// Immediate visual feedback on filter change. Without this the previously
|
|
10544
|
+
// rendered grid sits frozen until the 9-way UNION returns; on a cold cache
|
|
10545
|
+
// miss that's a couple seconds with zero indication anything is happening.
|
|
10546
|
+
const grid = document.getElementById('stats-grid');
|
|
10547
|
+
const totalEl = document.getElementById('stats-total');
|
|
10548
|
+
if (grid) grid.classList.add('is-loading');
|
|
10549
|
+
if (totalEl) totalEl.textContent = 'loading…';
|
|
9671
10550
|
try {
|
|
9672
10551
|
const hours = currentStatsWindow().hours;
|
|
9673
10552
|
const plat = currentStatsPlatform();
|
|
@@ -9678,7 +10557,9 @@ async function loadActivityStats() {
|
|
|
9678
10557
|
const res = await fetch('/api/activity/stats?' + params.join('&'));
|
|
9679
10558
|
const data = await res.json();
|
|
9680
10559
|
renderActivityStats(data);
|
|
9681
|
-
} catch {}
|
|
10560
|
+
} catch {} finally {
|
|
10561
|
+
if (grid) grid.classList.remove('is-loading');
|
|
10562
|
+
}
|
|
9682
10563
|
}
|
|
9683
10564
|
|
|
9684
10565
|
// Combined daily-metrics line chart (Trends tab). Fetches 4 endpoints (2
|
|
@@ -9698,6 +10579,14 @@ async function loadActivityStats() {
|
|
|
9698
10579
|
// to a capture day; expect those lines to sit at 0 until at least two
|
|
9699
10580
|
// consecutive days of snapshots have accumulated per post.
|
|
9700
10581
|
let DAILY_METRICS = [
|
|
10582
|
+
// Output volume: posts we made per day, split by type. 'threads' counts
|
|
10583
|
+
// posts where we authored the thread itself; 'comments_made' counts posts
|
|
10584
|
+
// where we engaged on someone else's thread. Both come from the same
|
|
10585
|
+
// /api/posts/per-day endpoint (server returns threads_made + comments_made
|
|
10586
|
+
// alongside posts_made). 'comments_made' is intentionally distinct from
|
|
10587
|
+
// the 'comments' pill below, which counts comments EARNED on our posts.
|
|
10588
|
+
{ id: 'threads', label: 'Threads', color: '#a855f7', endpoint: '/api/posts/per-day', valueKey: 'threads_made', platformAware: true },
|
|
10589
|
+
{ id: 'comments_made', label: 'Comments Made', color: '#d946ef', endpoint: '/api/posts/per-day', valueKey: 'comments_made', platformAware: true },
|
|
9701
10590
|
{ id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
|
|
9702
10591
|
{ id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
|
|
9703
10592
|
{ id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
|
|
@@ -10452,6 +11341,8 @@ async function loadDailyMetrics() {
|
|
|
10452
11341
|
intoSeries('bookings', bookings.rows, 'bookings_gained');
|
|
10453
11342
|
intoSeries('cost', cost.rows, 'cost_usd');
|
|
10454
11343
|
intoSeries('posts', posts.rows, 'posts_made');
|
|
11344
|
+
intoSeries('threads', posts.rows, 'threads_made');
|
|
11345
|
+
intoSeries('comments_made', posts.rows, 'comments_made');
|
|
10455
11346
|
DAILY_METRICS.filter(m => m.funnel).forEach(m => {
|
|
10456
11347
|
intoSeries(m.id, funnel.rows, m.valueKey);
|
|
10457
11348
|
});
|
|
@@ -10971,6 +11862,10 @@ function getStyleMeta() {
|
|
|
10971
11862
|
}
|
|
10972
11863
|
|
|
10973
11864
|
async function loadStyleStats() {
|
|
11865
|
+
const body = document.getElementById('style-stats-body');
|
|
11866
|
+
const totalEl = document.getElementById('style-stats-total');
|
|
11867
|
+
if (body) body.classList.add('is-loading');
|
|
11868
|
+
if (totalEl) totalEl.textContent = 'loading…';
|
|
10974
11869
|
try {
|
|
10975
11870
|
const platformRow = document.getElementById('style-stats-platform-pills');
|
|
10976
11871
|
const projectRow = document.getElementById('style-stats-project-pills');
|
|
@@ -10985,7 +11880,9 @@ async function loadStyleStats() {
|
|
|
10985
11880
|
getStyleMeta(),
|
|
10986
11881
|
]);
|
|
10987
11882
|
renderStyleStats(statsRes, meta);
|
|
10988
|
-
} catch {}
|
|
11883
|
+
} catch {} finally {
|
|
11884
|
+
if (body) body.classList.remove('is-loading');
|
|
11885
|
+
}
|
|
10989
11886
|
}
|
|
10990
11887
|
|
|
10991
11888
|
// Score-cohort distribution. Buckets posts in the trailing window into
|
|
@@ -11088,6 +11985,10 @@ function renderCohortStats(payload) {
|
|
|
11088
11985
|
}
|
|
11089
11986
|
|
|
11090
11987
|
async function loadCohortStats() {
|
|
11988
|
+
const body = document.getElementById('cohort-stats-body');
|
|
11989
|
+
const totalEl = document.getElementById('cohort-stats-total');
|
|
11990
|
+
if (body) body.classList.add('is-loading');
|
|
11991
|
+
if (totalEl) totalEl.textContent = 'loading…';
|
|
11091
11992
|
try {
|
|
11092
11993
|
const platformRow = document.getElementById('style-stats-platform-pills');
|
|
11093
11994
|
const projectRow = document.getElementById('style-stats-project-pills');
|
|
@@ -11101,8 +12002,9 @@ async function loadCohortStats() {
|
|
|
11101
12002
|
const data = await res.json();
|
|
11102
12003
|
renderCohortStats(data);
|
|
11103
12004
|
} catch (e) {
|
|
11104
|
-
const body = document.getElementById('cohort-stats-body');
|
|
11105
12005
|
if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load cohort stats.</div>';
|
|
12006
|
+
} finally {
|
|
12007
|
+
if (body) body.classList.remove('is-loading');
|
|
11106
12008
|
}
|
|
11107
12009
|
}
|
|
11108
12010
|
|
|
@@ -11637,34 +12539,40 @@ function renderCostStats(payload) {
|
|
|
11637
12539
|
const byType = {};
|
|
11638
12540
|
rows.forEach(r => { byType[r.type] = r; });
|
|
11639
12541
|
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 };
|
|
12542
|
+
const r = byType[t] || { count: 0, total_cost_usd: 0, total_cost_usd_orchestrator: 0, total_cost_usd_estimated: 0, total_cost_usd_subagent: 0 };
|
|
11641
12543
|
const count = Number(r.count) || 0;
|
|
11642
|
-
|
|
12544
|
+
// SDK-only: total = orchestrator. Estimate/subagent kept for diagnostic
|
|
12545
|
+
// tooltips. /api/cost/stats SQL emits total_cost_usd = SUM(per_row_cost)
|
|
12546
|
+
// which itself is SUM(orchestrator/rows), so it equals total_cost_usd_
|
|
12547
|
+
// orchestrator by construction in SDK-only mode.
|
|
11643
12548
|
const totalOrch = r.total_cost_usd_orchestrator != null ? Number(r.total_cost_usd_orchestrator) : null;
|
|
11644
12549
|
const totalEst = r.total_cost_usd_estimated != null ? Number(r.total_cost_usd_estimated) : null;
|
|
12550
|
+
const totalSub = r.total_cost_usd_subagent != null ? Number(r.total_cost_usd_subagent) : null;
|
|
12551
|
+
const total = totalOrch != null ? totalOrch : 0;
|
|
11645
12552
|
return {
|
|
11646
12553
|
type: t, label: COST_TYPE_LABELS[t], count: count,
|
|
11647
|
-
total: total, totalOrch: totalOrch, totalEst: totalEst,
|
|
12554
|
+
total: total, totalOrch: totalOrch, totalEst: totalEst, totalSub: totalSub,
|
|
11648
12555
|
avg: count > 0 ? total / count : 0,
|
|
11649
12556
|
avgOrch: count > 0 && totalOrch != null ? totalOrch / count : null,
|
|
11650
12557
|
avgEst: count > 0 && totalEst != null ? totalEst / count : null,
|
|
12558
|
+
avgSub: count > 0 && totalSub != null ? totalSub / count : null,
|
|
11651
12559
|
};
|
|
11652
12560
|
});
|
|
11653
12561
|
const totalCount = merged.reduce(function (a, r) { return a + r.count; }, 0);
|
|
11654
12562
|
const totalCost = merged.reduce(function (a, r) { return a + r.total; }, 0);
|
|
11655
12563
|
const totalOrch = merged.reduce(function (a, r) { return a + (r.totalOrch || 0); }, 0);
|
|
11656
12564
|
const totalEst = merged.reduce(function (a, r) { return a + (r.totalEst || 0); }, 0);
|
|
12565
|
+
const totalSub = merged.reduce(function (a, r) { return a + (r.totalSub || 0); }, 0);
|
|
11657
12566
|
if (totalEl) {
|
|
11658
12567
|
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
12568
|
const tipLines = [
|
|
11662
|
-
'
|
|
11663
|
-
'Estimated (transcript): $' + totalEst.toFixed(4),
|
|
12569
|
+
'Cost (SDK orchestrator): $' + totalOrch.toFixed(4),
|
|
11664
12570
|
'',
|
|
11665
|
-
'
|
|
12571
|
+
'Diagnostic-only (local pricing estimate, not actual billing):',
|
|
12572
|
+
' Transcript estimate: $' + totalEst.toFixed(4),
|
|
12573
|
+
' Subagent (est): $' + totalSub.toFixed(4),
|
|
11666
12574
|
'',
|
|
11667
|
-
'
|
|
12575
|
+
'Anthropic SDK-reported orchestrator_cost_usd. Pipelines whose wrappers don\\'t capture --orchestrator-cost-usd contribute $0 — see the per-phase table for which scripts are missing coverage.',
|
|
11668
12576
|
];
|
|
11669
12577
|
totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
|
|
11670
12578
|
totalEl.style.cursor = 'help';
|
|
@@ -11677,56 +12585,110 @@ function renderCostStats(payload) {
|
|
|
11677
12585
|
return '$' + n.toFixed(2);
|
|
11678
12586
|
}
|
|
11679
12587
|
function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
|
|
11680
|
-
|
|
11681
|
-
function moneyCell(displayed, orch, est) {
|
|
12588
|
+
function moneyCell(displayed, orch, est, sub) {
|
|
11682
12589
|
const tip = [
|
|
11683
|
-
'
|
|
11684
|
-
'Estimated (transcript): ' + (est != null ? fmtMoney(est) : 'n/a'),
|
|
12590
|
+
'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
|
|
11685
12591
|
'',
|
|
11686
|
-
'
|
|
12592
|
+
'Diagnostic-only (local pricing estimate):',
|
|
12593
|
+
' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
|
|
12594
|
+
' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
|
|
11687
12595
|
].join('\\n');
|
|
11688
12596
|
return '<span data-tooltip="' + escapeHtml(tip) +
|
|
11689
12597
|
'" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
|
|
11690
12598
|
fmtMoney(displayed) + '</span>';
|
|
11691
12599
|
}
|
|
11692
12600
|
const rowsHtml = merged.map(function (r) {
|
|
11693
|
-
const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst);
|
|
12601
|
+
const totalCellHtml = moneyCell(r.total, r.totalOrch, r.totalEst, r.totalSub);
|
|
12602
|
+
const subCellHtml = r.totalSub != null && r.totalSub > 0 ? fmtMoney(r.totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
|
|
11694
12603
|
const avgCellHtml = r.count > 0
|
|
11695
|
-
? moneyCell(r.avg, r.avgOrch, r.avgEst)
|
|
12604
|
+
? moneyCell(r.avg, r.avgOrch, r.avgEst, r.avgSub)
|
|
11696
12605
|
: '—';
|
|
11697
12606
|
return '<tr>' +
|
|
11698
12607
|
'<td>' + escapeHtml(r.label) + '</td>' +
|
|
11699
12608
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(r.count) + '</td>' +
|
|
11700
12609
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellHtml + '</td>' +
|
|
12610
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + subCellHtml + '</td>' +
|
|
11701
12611
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + avgCellHtml + '</td>' +
|
|
11702
12612
|
'</tr>';
|
|
11703
12613
|
}).join('');
|
|
11704
|
-
const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst);
|
|
12614
|
+
const footerTotalHtml = moneyCell(totalCost, totalOrch, totalEst, totalSub);
|
|
12615
|
+
const footerSubHtml = totalSub > 0 ? fmtMoney(totalSub) : '<span style="color:var(--text-very-faint);">$0</span>';
|
|
11705
12616
|
const footerAvgHtml = totalCount > 0
|
|
11706
12617
|
? moneyCell(totalCost / totalCount,
|
|
11707
12618
|
totalOrch / totalCount,
|
|
11708
|
-
totalEst / totalCount
|
|
12619
|
+
totalEst / totalCount,
|
|
12620
|
+
totalSub / totalCount)
|
|
11709
12621
|
: '—';
|
|
11710
12622
|
const footerHtml =
|
|
11711
12623
|
'<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
|
|
11712
12624
|
'<td>Total</td>' +
|
|
11713
12625
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(totalCount) + '</td>' +
|
|
11714
12626
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerTotalHtml + '</td>' +
|
|
12627
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerSubHtml + '</td>' +
|
|
11715
12628
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + footerAvgHtml + '</td>' +
|
|
11716
12629
|
'</tr>';
|
|
12630
|
+
// Per-phase (script) breakdown. Same window, separate query — gives the
|
|
12631
|
+
// operator an answer to "which phase of which pipeline is burning cash?"
|
|
12632
|
+
// independent of the activity-type rollup above. A row like
|
|
12633
|
+
// run-twitter-cycle-scan dominating the spend is the signal to investigate.
|
|
12634
|
+
const phases = (payload && payload.phases) || [];
|
|
12635
|
+
let phaseTableHtml = '';
|
|
12636
|
+
if (phases.length) {
|
|
12637
|
+
const phaseRowsHtml = phases.map(function (p) {
|
|
12638
|
+
const orch = p.total_cost_usd_orchestrator != null ? Number(p.total_cost_usd_orchestrator) : 0;
|
|
12639
|
+
const est = p.total_cost_usd_estimated != null ? Number(p.total_cost_usd_estimated) : null;
|
|
12640
|
+
const sub = p.total_cost_usd_subagent != null ? Number(p.total_cost_usd_subagent) : null;
|
|
12641
|
+
const sessions = Number(p.sessions) || 0;
|
|
12642
|
+
const withSdk = Number(p.sessions_with_sdk) || 0;
|
|
12643
|
+
const missing = Math.max(0, sessions - withSdk);
|
|
12644
|
+
// SDK-only: per-phase total = orchestrator sum. Phases with 0% SDK
|
|
12645
|
+
// coverage show $0 with a "(N/N missing)" hint so it's obvious the
|
|
12646
|
+
// wrapper needs patching.
|
|
12647
|
+
const totalCellInner = withSdk > 0
|
|
12648
|
+
? moneyCell(orch, orch, est, sub)
|
|
12649
|
+
: '<span style="color:var(--text-very-faint);">n/a</span>';
|
|
12650
|
+
const coverageCell = missing === 0
|
|
12651
|
+
? ('<span style="color:#15803d;">' + sessions + '/' + sessions + '</span>')
|
|
12652
|
+
: ('<span style="color:#b91c1c;" title="' + missing + ' sessions missing SDK cost (wrapper script needs --output-format json on claude call)">' +
|
|
12653
|
+
withSdk + '/' + sessions + '</span>');
|
|
12654
|
+
const perSession = withSdk > 0
|
|
12655
|
+
? moneyCell(orch / withSdk, orch / withSdk, est != null ? est / sessions : null, sub != null ? sub / sessions : null)
|
|
12656
|
+
: '—';
|
|
12657
|
+
return '<tr>' +
|
|
12658
|
+
'<td style="font-family:ui-monospace,monospace;font-size:12px;">' + escapeHtml(p.phase || '(unknown)') + '</td>' +
|
|
12659
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + fmtCount(sessions) + '</td>' +
|
|
12660
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;font-size:11px;">' + coverageCell + '</td>' +
|
|
12661
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + totalCellInner + '</td>' +
|
|
12662
|
+
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + perSession + '</td>' +
|
|
12663
|
+
'</tr>';
|
|
12664
|
+
}).join('');
|
|
12665
|
+
phaseTableHtml =
|
|
12666
|
+
'<div style="font-size:12px;font-weight:600;padding:12px 2px 4px;color:var(--text-secondary);">Cost per Phase (Claude session script)</div>' +
|
|
12667
|
+
'<table class="style-stats-table">' +
|
|
12668
|
+
'<thead><tr>' +
|
|
12669
|
+
'<th style="text-align:left;">Phase</th>' +
|
|
12670
|
+
'<th style="text-align:right;">Sessions</th>' +
|
|
12671
|
+
'<th style="text-align:right;" title="Sessions with SDK cost captured / total sessions. Red = wrapper needs patching.">SDK coverage</th>' +
|
|
12672
|
+
'<th style="text-align:right;">Cost (SDK)</th>' +
|
|
12673
|
+
'<th style="text-align:right;">Cost per Session</th>' +
|
|
12674
|
+
'</tr></thead>' +
|
|
12675
|
+
'<tbody>' + phaseRowsHtml + '</tbody>' +
|
|
12676
|
+
'</table>';
|
|
12677
|
+
}
|
|
11717
12678
|
body.innerHTML =
|
|
11718
12679
|
'<table class="style-stats-table">' +
|
|
11719
12680
|
'<thead><tr>' +
|
|
11720
12681
|
'<th style="text-align:left;">Type</th>' +
|
|
11721
12682
|
'<th style="text-align:right;">Activities</th>' +
|
|
11722
12683
|
'<th style="text-align:right;">Total Cost</th>' +
|
|
12684
|
+
'<th style="text-align:right;">Subagent</th>' +
|
|
11723
12685
|
'<th style="text-align:right;">Cost per Activity</th>' +
|
|
11724
12686
|
'</tr></thead>' +
|
|
11725
12687
|
'<tbody>' + rowsHtml + footerHtml + '</tbody>' +
|
|
11726
12688
|
'</table>' +
|
|
12689
|
+
phaseTableHtml +
|
|
11727
12690
|
'<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.' +
|
|
12691
|
+
'SDK-only mode (2026-05-15): cost = Anthropic\\'s orchestrator_cost_usd from the SDK result event. Pipelines whose wrappers don\\'t pass --output-format json to claude show $0 with a red SDK-coverage cell — that\\'s a missing-data signal, not a real $0. Transcript estimate and Task-subagent figures (tooltip-only) come from a local pricing table and don\\'t reflect subscription billing.' +
|
|
11730
12692
|
'</div>';
|
|
11731
12693
|
}
|
|
11732
12694
|
|
|
@@ -11779,6 +12741,10 @@ let _topDmsTableState = { sortField: 'rank', sortDir: 'asc', filters: {} };
|
|
|
11779
12741
|
let _topDmsLoaded = false;
|
|
11780
12742
|
let _topDmsLoading = false;
|
|
11781
12743
|
let _topDmsPayload = null;
|
|
12744
|
+
let _topLinksTableState = { sortField: 'real_clicks', sortDir: 'desc', filters: {} };
|
|
12745
|
+
let _topLinksLoaded = false;
|
|
12746
|
+
let _topLinksLoading = false;
|
|
12747
|
+
let _topLinksPayload = null;
|
|
11782
12748
|
let _topDmDir = saLoad('sa.top.dmDir.v1', 'all');
|
|
11783
12749
|
let _topDmInterest = saLoad('sa.top.dmInterest.v1', 'all');
|
|
11784
12750
|
let _topDmMode = saLoad('sa.top.dmMode.v1', 'all');
|
|
@@ -12311,6 +13277,7 @@ const TOP_SUBTAB_HELP = {
|
|
|
12311
13277
|
comments: 'Top comments your accounts have left under other people’s threads, ranked by reach and reactions.',
|
|
12312
13278
|
pages: 'Top landing/SEO pages on your sites this period, ranked by pageviews.',
|
|
12313
13279
|
dms: 'Direct message conversations with prospects, ranked by recent activity.',
|
|
13280
|
+
links: 'Destination URLs across all posts, ranked by clicks. One row per unique target URL (homepage vs audience pages vs SEO pages vs booking).',
|
|
12314
13281
|
};
|
|
12315
13282
|
function syncTopSubtabHelp() {
|
|
12316
13283
|
const el = document.getElementById('top-subtab-help');
|
|
@@ -12363,12 +13330,14 @@ function initTopFilters() {
|
|
|
12363
13330
|
saveDashboardWindow(_topWindow);
|
|
12364
13331
|
if (_topSubtab === 'pages') loadTopPages(true);
|
|
12365
13332
|
else if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
|
|
13333
|
+
else if (_topSubtab === 'links') loadTopLinks(true);
|
|
12366
13334
|
else loadTopPosts(true);
|
|
12367
13335
|
});
|
|
12368
13336
|
wireTopPillRow('top-platform-pills', (v) => {
|
|
12369
13337
|
_topPlatform = v || 'all';
|
|
12370
13338
|
saSave('sa.top.platform.v1', _topPlatform);
|
|
12371
13339
|
if (_topSubtab === 'dms') { _topDmOffset = 0; loadTopDms(true); }
|
|
13340
|
+
else if (_topSubtab === 'links') loadTopLinks(true);
|
|
12372
13341
|
else loadTopPosts(true);
|
|
12373
13342
|
});
|
|
12374
13343
|
wireTopPillRow('top-project-pills', (v) => {
|
|
@@ -12376,6 +13345,7 @@ function initTopFilters() {
|
|
|
12376
13345
|
saSave('sa.top.project.v1', _topProject);
|
|
12377
13346
|
if (_topSubtab === 'pages') renderTopPagesFromCache();
|
|
12378
13347
|
else if (_topSubtab === 'dms') { if (_topDmsPayload) renderTopDms(_topDmsPayload); }
|
|
13348
|
+
else if (_topSubtab === 'links') loadTopLinks(true);
|
|
12379
13349
|
else loadTopPosts(true); // refetch so the SQL LIMIT applies AFTER project filter
|
|
12380
13350
|
});
|
|
12381
13351
|
wireTopPillRow('top-campaign-pills', (v) => {
|
|
@@ -12494,6 +13464,7 @@ function applyTopSubtabState(sub, loadData) {
|
|
|
12494
13464
|
const pagesC = document.getElementById('top-pages-container');
|
|
12495
13465
|
const pagesUnknownC = document.getElementById('top-pages-unknown-container');
|
|
12496
13466
|
const dmsC = document.getElementById('top-dms-container');
|
|
13467
|
+
const linksC = document.getElementById('top-links-container');
|
|
12497
13468
|
const platRowEl = document.getElementById('top-platform-pills');
|
|
12498
13469
|
const projRowEl = document.getElementById('top-project-pills');
|
|
12499
13470
|
const campRowEl = document.getElementById('top-campaign-pills');
|
|
@@ -12520,6 +13491,7 @@ function applyTopSubtabState(sub, loadData) {
|
|
|
12520
13491
|
if (sub === 'pages') {
|
|
12521
13492
|
if (postsC) postsC.classList.add('hidden');
|
|
12522
13493
|
if (dmsC) dmsC.classList.add('hidden');
|
|
13494
|
+
if (linksC) linksC.classList.add('hidden');
|
|
12523
13495
|
if (pagesC) pagesC.classList.remove('hidden');
|
|
12524
13496
|
if (pagesUnknownC) pagesUnknownC.classList.remove('hidden');
|
|
12525
13497
|
if (platRowEl) platRowEl.classList.add('hidden');
|
|
@@ -12533,6 +13505,7 @@ function applyTopSubtabState(sub, loadData) {
|
|
|
12533
13505
|
if (postsC) postsC.classList.add('hidden');
|
|
12534
13506
|
if (pagesC) pagesC.classList.add('hidden');
|
|
12535
13507
|
if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
|
|
13508
|
+
if (linksC) linksC.classList.add('hidden');
|
|
12536
13509
|
if (dmsC) dmsC.classList.remove('hidden');
|
|
12537
13510
|
if (platRowEl) platRowEl.classList.remove('hidden');
|
|
12538
13511
|
if (srcRowEl) srcRowEl.classList.add('hidden');
|
|
@@ -12546,10 +13519,29 @@ function applyTopSubtabState(sub, loadData) {
|
|
|
12546
13519
|
searchElDm.value = _topDmSearch || '';
|
|
12547
13520
|
}
|
|
12548
13521
|
if (loadData) loadTopDms(true);
|
|
13522
|
+
} else if (sub === 'links') {
|
|
13523
|
+
if (postsC) postsC.classList.add('hidden');
|
|
13524
|
+
if (pagesC) pagesC.classList.add('hidden');
|
|
13525
|
+
if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
|
|
13526
|
+
if (dmsC) dmsC.classList.add('hidden');
|
|
13527
|
+
if (linksC) linksC.classList.remove('hidden');
|
|
13528
|
+
if (platRowEl) platRowEl.classList.remove('hidden');
|
|
13529
|
+
if (srcRowEl) srcRowEl.classList.add('hidden');
|
|
13530
|
+
if (campRowEl) campRowEl.classList.add('hidden');
|
|
13531
|
+
setDmRowsHidden(true);
|
|
13532
|
+
setLinkPillHidden(true);
|
|
13533
|
+
if (totalEl) totalEl.textContent = '';
|
|
13534
|
+
const searchElLinks = document.getElementById('top-search');
|
|
13535
|
+
if (searchElLinks) {
|
|
13536
|
+
searchElLinks.placeholder = 'Filter destinations by URL\u2026';
|
|
13537
|
+
searchElLinks.value = (_topLinksTableState && _topLinksTableState.globalQuery) || '';
|
|
13538
|
+
}
|
|
13539
|
+
if (loadData) loadTopLinks(true);
|
|
12549
13540
|
} else {
|
|
12550
13541
|
if (pagesC) pagesC.classList.add('hidden');
|
|
12551
13542
|
if (pagesUnknownC) pagesUnknownC.classList.add('hidden');
|
|
12552
13543
|
if (dmsC) dmsC.classList.add('hidden');
|
|
13544
|
+
if (linksC) linksC.classList.add('hidden');
|
|
12553
13545
|
if (postsC) postsC.classList.remove('hidden');
|
|
12554
13546
|
if (platRowEl) platRowEl.classList.remove('hidden');
|
|
12555
13547
|
if (srcRowEl) srcRowEl.classList.add('hidden');
|
|
@@ -12729,6 +13721,151 @@ async function loadTopPages(force) {
|
|
|
12729
13721
|
}
|
|
12730
13722
|
}
|
|
12731
13723
|
|
|
13724
|
+
// Render the colored kind badge for a destination row. The server's
|
|
13725
|
+
// /api/top/destinations endpoint classifies each row into one of seven kind
|
|
13726
|
+
// buckets (home / subpage / audience_page / seo / booking / github /
|
|
13727
|
+
// external / other) by reading config.json, so the client just looks up
|
|
13728
|
+
// the label and color here.
|
|
13729
|
+
function destinationKindBadge(kind, audienceAngle) {
|
|
13730
|
+
const map = {
|
|
13731
|
+
home: { cls: 'dest-kind-home', label: 'HOME' },
|
|
13732
|
+
subpage: { cls: 'dest-kind-subpage', label: 'SUBPAGE' },
|
|
13733
|
+
audience_page: { cls: 'dest-kind-subpage', label: 'AUDIENCE' },
|
|
13734
|
+
seo: { cls: 'dest-kind-seo', label: 'SEO' },
|
|
13735
|
+
booking: { cls: 'dest-kind-booking', label: 'BOOKING' },
|
|
13736
|
+
github: { cls: 'dest-kind-github', label: 'GITHUB' },
|
|
13737
|
+
external: { cls: 'dest-kind-external', label: 'EXT' },
|
|
13738
|
+
other: { cls: 'dest-kind-other', label: 'OTHER' },
|
|
13739
|
+
};
|
|
13740
|
+
const m = map[kind] || map.other;
|
|
13741
|
+
let label = m.label;
|
|
13742
|
+
if (kind === 'audience_page' && audienceAngle) {
|
|
13743
|
+
label = 'AUDIENCE: ' + audienceAngle;
|
|
13744
|
+
}
|
|
13745
|
+
return '<span class="dest-kind-badge ' + m.cls + '" title="' + escapeHtml(kind) + '">' + escapeHtml(label) + '</span>';
|
|
13746
|
+
}
|
|
13747
|
+
|
|
13748
|
+
async function loadTopLinks(force) {
|
|
13749
|
+
if (_topLinksLoading) return;
|
|
13750
|
+
const container = document.getElementById('top-links-container');
|
|
13751
|
+
if (!_topLinksPayload && container) {
|
|
13752
|
+
container.innerHTML = '<div class="style-stats-empty">Loading\u2026</div>';
|
|
13753
|
+
}
|
|
13754
|
+
_topLinksLoading = true;
|
|
13755
|
+
try {
|
|
13756
|
+
const params = new URLSearchParams();
|
|
13757
|
+
if (_topWindow) params.set('window', _topWindow);
|
|
13758
|
+
if (_topPlatform && _topPlatform !== 'all') params.set('platform', _topPlatform);
|
|
13759
|
+
if (_topProject && _topProject !== 'all') params.set('project', _topProject);
|
|
13760
|
+
const res = await fetch('/api/top/destinations?' + params.toString());
|
|
13761
|
+
const data = await res.json();
|
|
13762
|
+
_topLinksPayload = data;
|
|
13763
|
+
renderTopLinks(data);
|
|
13764
|
+
_topLinksLoaded = true;
|
|
13765
|
+
} catch (e) {
|
|
13766
|
+
if (container) container.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
|
|
13767
|
+
} finally {
|
|
13768
|
+
_topLinksLoading = false;
|
|
13769
|
+
}
|
|
13770
|
+
}
|
|
13771
|
+
|
|
13772
|
+
function renderTopLinks(payload) {
|
|
13773
|
+
const container = document.getElementById('top-links-container');
|
|
13774
|
+
if (!container) return;
|
|
13775
|
+
const totalEl = document.getElementById('top-total');
|
|
13776
|
+
const fmt = n => (Number(n) || 0).toLocaleString();
|
|
13777
|
+
const dests = Array.isArray(payload && payload.destinations) ? payload.destinations : [];
|
|
13778
|
+
if (!dests.length) {
|
|
13779
|
+
container.innerHTML = '<div class="style-stats-empty">No destinations in this window yet. Posts with linked URLs will show up here once they accrue clicks.</div>';
|
|
13780
|
+
if (totalEl) totalEl.textContent = '';
|
|
13781
|
+
return;
|
|
13782
|
+
}
|
|
13783
|
+
// Roll up real_clicks vs the legacy/backfill columns: prefer plc.real_clicks
|
|
13784
|
+
// (post-2026-05-07 per-hit log), fall back to pl.real_clicks (PostHog
|
|
13785
|
+
// backfill for older rows), final fallback pl.clicks (legacy counter).
|
|
13786
|
+
const rows = dests.map(d => {
|
|
13787
|
+
const kind = d.kind || 'other';
|
|
13788
|
+
const realClicks = Number(d.real_clicks || 0);
|
|
13789
|
+
const backfillReal = Number(d.backfill_real || 0);
|
|
13790
|
+
const legacyClicks = Number(d.legacy_clicks || 0);
|
|
13791
|
+
const botClicks = Number(d.bot_clicks || 0);
|
|
13792
|
+
const effectiveClicks = realClicks > 0 ? realClicks : (backfillReal > 0 ? backfillReal : legacyClicks);
|
|
13793
|
+
return {
|
|
13794
|
+
target_url: d.target_url || '',
|
|
13795
|
+
project_name: d.project_name || '',
|
|
13796
|
+
platform: d.platform || '',
|
|
13797
|
+
kind,
|
|
13798
|
+
audience_page_angle: d.audience_page_angle || null,
|
|
13799
|
+
kind_label: kind,
|
|
13800
|
+
posts: Number(d.posts || 0),
|
|
13801
|
+
codes: Number(d.codes || 0),
|
|
13802
|
+
real_clicks: realClicks,
|
|
13803
|
+
backfill_real: backfillReal,
|
|
13804
|
+
legacy_clicks: legacyClicks,
|
|
13805
|
+
bot_clicks: botClicks,
|
|
13806
|
+
effective_clicks: effectiveClicks,
|
|
13807
|
+
first_minted_at: d.first_minted_at || null,
|
|
13808
|
+
last_click_at: d.last_click_at || null,
|
|
13809
|
+
};
|
|
13810
|
+
});
|
|
13811
|
+
if (totalEl) {
|
|
13812
|
+
const totalClicks = rows.reduce((a, r) => a + r.effective_clicks, 0);
|
|
13813
|
+
totalEl.textContent = rows.length + ' destination' + (rows.length === 1 ? '' : 's') + ' \u00b7 ' + fmt(totalClicks) + ' clicks';
|
|
13814
|
+
}
|
|
13815
|
+
const fmtUrl = (_v, r) => {
|
|
13816
|
+
const safe = escapeHtml(r.target_url);
|
|
13817
|
+
return '<a href="' + safe + '" target="_blank" rel="noopener" class="top-post-link">'
|
|
13818
|
+
+ destinationKindBadge(r.kind, r.audience_page_angle)
|
|
13819
|
+
+ ' <span style="word-break:break-all">' + safe + '</span>'
|
|
13820
|
+
+ '</a>';
|
|
13821
|
+
};
|
|
13822
|
+
const fmtAgo = (v) => {
|
|
13823
|
+
if (!v) return '\u2014';
|
|
13824
|
+
try {
|
|
13825
|
+
const d = new Date(v);
|
|
13826
|
+
const diff = Date.now() - d.getTime();
|
|
13827
|
+
const days = Math.floor(diff / 86400000);
|
|
13828
|
+
if (days < 1) {
|
|
13829
|
+
const hours = Math.floor(diff / 3600000);
|
|
13830
|
+
if (hours < 1) {
|
|
13831
|
+
const mins = Math.max(0, Math.floor(diff / 60000));
|
|
13832
|
+
return mins + 'm ago';
|
|
13833
|
+
}
|
|
13834
|
+
return hours + 'h ago';
|
|
13835
|
+
}
|
|
13836
|
+
return days + 'd ago';
|
|
13837
|
+
} catch (_e) { return '\u2014'; }
|
|
13838
|
+
};
|
|
13839
|
+
const fmtClicks = (v, r) => {
|
|
13840
|
+
const main = fmt(r.effective_clicks);
|
|
13841
|
+
const bits = [];
|
|
13842
|
+
if (r.real_clicks > 0) bits.push(r.real_clicks + ' real');
|
|
13843
|
+
else if (r.backfill_real > 0) bits.push(r.backfill_real + ' backfill');
|
|
13844
|
+
else if (r.legacy_clicks > 0) bits.push(r.legacy_clicks + ' legacy');
|
|
13845
|
+
if (r.bot_clicks > 0) bits.push(r.bot_clicks + ' bot');
|
|
13846
|
+
const sub = bits.length ? '<div style="font-size:11px;color:var(--text-secondary)">' + escapeHtml(bits.join(' \u00b7 ')) + '</div>' : '';
|
|
13847
|
+
return '<div style="font-weight:600">' + main + '</div>' + sub;
|
|
13848
|
+
};
|
|
13849
|
+
const columns = [
|
|
13850
|
+
{ key: 'target_url', label: 'Destination', type: 'text', align: 'left', widthPct: 48, formatter: fmtUrl },
|
|
13851
|
+
{ key: 'project_name', label: 'Project', type: 'text', align: 'left', widthPct: 10, formatter: v => escapeHtml(v) },
|
|
13852
|
+
{ key: 'platform', label: 'Platform', type: 'text', align: 'left', widthPct: 8, formatter: v => escapeHtml(v) },
|
|
13853
|
+
{ key: 'posts', label: 'Posts', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
|
|
13854
|
+
{ key: 'codes', label: 'Codes', type: 'numeric', align: 'right', widthPct: 7, formatter: fmt },
|
|
13855
|
+
{ key: 'effective_clicks', label: 'Clicks', type: 'numeric', align: 'right', widthPct: 12, formatter: fmtClicks },
|
|
13856
|
+
{ key: 'last_click_at', label: 'Last click', type: 'date', align: 'right', widthPct: 8, formatter: fmtAgo },
|
|
13857
|
+
];
|
|
13858
|
+
container.innerHTML = '';
|
|
13859
|
+
mountSortableTable({
|
|
13860
|
+
containerId: 'top-links-container',
|
|
13861
|
+
rows,
|
|
13862
|
+
state: _topLinksTableState,
|
|
13863
|
+
storageKey: 'sa.topLinksTable.v1',
|
|
13864
|
+
columns,
|
|
13865
|
+
emptyMessage: 'No destinations in this window yet.',
|
|
13866
|
+
});
|
|
13867
|
+
}
|
|
13868
|
+
|
|
12732
13869
|
function dmClassBadge(dm) {
|
|
12733
13870
|
const status = String(dm.conversation_status || '').toLowerCase();
|
|
12734
13871
|
const interest = String(dm.interest_level || '').toLowerCase();
|
|
@@ -13960,6 +15097,7 @@ function renderProjectStatus(data, opts) {
|
|
|
13960
15097
|
const grandCost = Number(data && data.grand_cost_usd) || 0;
|
|
13961
15098
|
const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
|
|
13962
15099
|
const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
|
|
15100
|
+
const grandCostSub = Number(data && data.grand_cost_usd_subagent) || 0;
|
|
13963
15101
|
// Money formatter mirrors fmtCost: $0, $0.0042, $12.34.
|
|
13964
15102
|
const fmtMoney = (v) => {
|
|
13965
15103
|
const n = Number(v) || 0;
|
|
@@ -13969,12 +15107,17 @@ function renderProjectStatus(data, opts) {
|
|
|
13969
15107
|
};
|
|
13970
15108
|
// Money cell with tooltip exposing SDK + estimate lanes, same UX as
|
|
13971
15109
|
// moneyCell in renderCostStats so operators see consistent numbers.
|
|
13972
|
-
const costCell = (displayed, orch, est, opts) => {
|
|
15110
|
+
const costCell = (displayed, orch, est, sub, opts) => {
|
|
15111
|
+
// SDK-only mode: displayed value comes from orchestrator_cost_usd; the
|
|
15112
|
+
// estimate and subagent are diagnostic-only (local pricing table).
|
|
13973
15113
|
const tip = [
|
|
13974
|
-
'
|
|
13975
|
-
'
|
|
15114
|
+
'Cost (SDK orchestrator): ' + (orch != null ? fmtMoney(orch) : 'n/a'),
|
|
15115
|
+
'',
|
|
15116
|
+
'Diagnostic-only (local pricing estimate, not actual billing):',
|
|
15117
|
+
' Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
|
|
15118
|
+
' Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
|
|
13976
15119
|
'',
|
|
13977
|
-
'
|
|
15120
|
+
'Anthropic SDK-reported cost only. "n/a" or $0 means the wrapper didn\\'t capture --orchestrator-cost-usd (no --output-format json on the claude call) for one or more sessions in this window. Subagent and transcript estimates are computed from a local pricing table and are not billing-accurate on subscription plans.',
|
|
13978
15121
|
].join('\\n');
|
|
13979
15122
|
const style = 'text-align:right;font-variant-numeric:tabular-nums;' + (opts && opts.extra || '');
|
|
13980
15123
|
const inner = '<span data-tooltip="' + escapeHtml(tip) +
|
|
@@ -13990,10 +15133,13 @@ function renderProjectStatus(data, opts) {
|
|
|
13990
15133
|
: base;
|
|
13991
15134
|
if (costAvailable) {
|
|
13992
15135
|
const tipLines = [
|
|
13993
|
-
'
|
|
13994
|
-
'Estimated (transcript): ' + fmtMoney(grandCostEst),
|
|
15136
|
+
'Cost (SDK orchestrator): ' + fmtMoney(grandCostOrch),
|
|
13995
15137
|
'',
|
|
13996
|
-
'
|
|
15138
|
+
'Diagnostic-only (local pricing estimate, not actual billing):',
|
|
15139
|
+
' Transcript estimate: ' + fmtMoney(grandCostEst),
|
|
15140
|
+
' Subagent (est): ' + fmtMoney(grandCostSub),
|
|
15141
|
+
'',
|
|
15142
|
+
'Anthropic SDK-reported orchestrator_cost_usd across all activity rows in this window. Per-session attribution is the session\\'s cost split evenly across its activity rows. Pipelines whose wrappers don\\'t pass --output-format json to claude contribute $0.',
|
|
13997
15143
|
];
|
|
13998
15144
|
totalEl.setAttribute('data-tooltip', tipLines.join('\\n'));
|
|
13999
15145
|
totalEl.style.cursor = 'help';
|
|
@@ -14072,7 +15218,7 @@ function renderProjectStatus(data, opts) {
|
|
|
14072
15218
|
: nameCell;
|
|
14073
15219
|
const totalCell = cellWithShare(r.total, grandTotal, targetShare, { extra: 'font-weight:600;', showZeroShare: true });
|
|
14074
15220
|
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);' })
|
|
15221
|
+
? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, Number(r.cost_usd_subagent) || 0, { extra: 'color:var(--text-secondary);' })
|
|
14076
15222
|
: '';
|
|
14077
15223
|
const weightVal = Number(r.weight) || 0;
|
|
14078
15224
|
const editable = canEditWeight && (!r.unassigned || r.configured);
|
|
@@ -14108,7 +15254,7 @@ function renderProjectStatus(data, opts) {
|
|
|
14108
15254
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
|
|
14109
15255
|
).join('');
|
|
14110
15256
|
const footerCostCell = costAvailable
|
|
14111
|
-
? costCell(grandCost, grandCostOrch, grandCostEst, { extra: 'font-weight:600;' })
|
|
15257
|
+
? costCell(grandCost, grandCostOrch, grandCostEst, grandCostSub, { extra: 'font-weight:600;' })
|
|
14112
15258
|
: '';
|
|
14113
15259
|
const footerHtml =
|
|
14114
15260
|
'<tr style="border-top:2px solid var(--border);font-weight:600;background:var(--bg-subtle);">' +
|
|
@@ -14451,7 +15597,7 @@ function renderActivity(events) {
|
|
|
14451
15597
|
'</div>' +
|
|
14452
15598
|
'</td>' +
|
|
14453
15599
|
'<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>' +
|
|
15600
|
+
'<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated, e.cost_usd_subagent) + '</td>' +
|
|
14455
15601
|
'<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
|
|
14456
15602
|
'</tr>';
|
|
14457
15603
|
}).join('');
|
|
@@ -15013,6 +16159,25 @@ function renderHtml() {
|
|
|
15013
16159
|
.replace('__SA_POSTHOG_CONFIG_PLACEHOLDER__', JSON.stringify(posthogWebConfig()));
|
|
15014
16160
|
}
|
|
15015
16161
|
|
|
16162
|
+
function renderTikTokOauthCallback(rawUrl) {
|
|
16163
|
+
const u = new URL(rawUrl, 'http://localhost');
|
|
16164
|
+
const code = u.searchParams.get('code') || '';
|
|
16165
|
+
const state = u.searchParams.get('state') || '';
|
|
16166
|
+
const scopes = u.searchParams.get('scopes') || '';
|
|
16167
|
+
const err = u.searchParams.get('error') || '';
|
|
16168
|
+
const errDesc = u.searchParams.get('error_description') || '';
|
|
16169
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
16170
|
+
const ok = !!code && !err;
|
|
16171
|
+
const banner = ok
|
|
16172
|
+
? '<div style="background:#ecfdf5;color:#065f46;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization successful. Copy the code below and paste it into the local oauth_helper.py script.</div>'
|
|
16173
|
+
: `<div style="background:#fef2f2;color:#991b1b;padding:12px 16px;border-radius:8px;margin-bottom:24px">Authorization error: ${esc(err || 'no code in callback')}${errDesc ? ` — ${esc(errDesc)}` : ''}</div>`;
|
|
16174
|
+
const codeBox = ok ? `<div><label style="display:block;font-size:13px;color:#52525b;margin-bottom:6px">Authorization code</label><textarea id="tt-code" readonly style="width:100%;font-family:ui-monospace,SFMono-Regular,monospace;font-size:14px;padding:10px;border:1px solid #d4d4d8;border-radius:8px;background:#fafafa" rows="3" onclick="this.select()">${esc(code)}</textarea><button id="tt-copy" style="margin-top:8px;padding:8px 14px;background:#18181b;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px">Copy</button></div><script>(function(){var b=document.getElementById('tt-copy'),t=document.getElementById('tt-code');if(b&&t){b.addEventListener('click',function(){navigator.clipboard.writeText(t.value).then(function(){b.textContent='Copied';setTimeout(function(){b.textContent='Copy'},1500)})})}})();</script>` : '';
|
|
16175
|
+
const next = ok ? `<details style="margin-top:24px"><summary style="cursor:pointer;color:#27272a;font-weight:500">Next steps</summary><pre style="background:#0a0a0a;color:#fafafa;padding:16px;border-radius:8px;overflow:auto;font-size:13px;line-height:1.6;margin-top:12px">cd ~/social-autoposter
|
|
16176
|
+
python3 scripts/tiktok/oauth_helper.py exchange '${esc(code)}'</pre><p style="color:#52525b;font-size:13px;margin-top:12px">This exchanges the code for an access token + refresh token via the TikTok API using the client secret stored in your local macOS keychain, then writes the tokens to <code>~/tiktok-content-api/.env</code>.</p></details>` : '';
|
|
16177
|
+
const meta = ok ? `<dl style="display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin-top:24px;font-size:13px;color:#52525b"><dt>state</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(state) || '<i>(none)</i>'}</dd><dt>scopes</dt><dd style="font-family:ui-monospace,SFMono-Regular,monospace">${esc(scopes) || '<i>(none)</i>'}</dd></dl>` : '';
|
|
16178
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>TikTok OAuth callback · Meditation Fellow Studio</title><meta name="robots" content="noindex"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;max-width:680px;margin:48px auto;padding:0 24px;color:#18181b;background:#fff;line-height:1.5}h1{font-size:24px;font-weight:600;margin:0 0 24px;letter-spacing:-0.01em}code{font-family:ui-monospace,SFMono-Regular,monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px;font-size:0.9em}</style></head><body><h1>TikTok OAuth callback</h1>${banner}${codeBox}${meta}${next}</body></html>`;
|
|
16179
|
+
}
|
|
16180
|
+
|
|
15016
16181
|
// --- Server ---
|
|
15017
16182
|
|
|
15018
16183
|
const server = http.createServer((req, res) => {
|
|
@@ -15038,6 +16203,14 @@ const server = http.createServer((req, res) => {
|
|
|
15038
16203
|
Promise.resolve(handleApi(req, res)).catch(e => {
|
|
15039
16204
|
try { json(res, { error: e.message || String(e) }, 500); } catch {}
|
|
15040
16205
|
});
|
|
16206
|
+
} else if (pathname === '/oauth/tiktok/callback') {
|
|
16207
|
+
// Minimal TikTok OAuth landing page. We do not exchange the code here
|
|
16208
|
+
// because the dashboard Cloud Run service does not carry the TikTok
|
|
16209
|
+
// client_secret. The operator pastes the displayed code into the local
|
|
16210
|
+
// scripts/tiktok/oauth_helper.py script which does the exchange via
|
|
16211
|
+
// keychain-stored credentials and writes ~/tiktok-content-api/.env.
|
|
16212
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
16213
|
+
res.end(renderTikTokOauthCallback(req.url));
|
|
15041
16214
|
} else {
|
|
15042
16215
|
res.writeHead(404);
|
|
15043
16216
|
res.end('Not found');
|