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