social-autoposter 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/server.js +1209 -241
- package/package.json +1 -1
- package/schema-postgres.sql +16 -0
- package/scripts/dm_send_log.py +7 -0
- package/scripts/dm_short_links.py +54 -7
- package/scripts/engagement_styles.py +356 -10
- package/scripts/ig_post_type_picker.py +123 -24
- package/scripts/insert_post029.py +4 -1
- package/scripts/insert_post_051.py +85 -0
- package/scripts/log_post.py +43 -3
- package/scripts/post_reddit.py +83 -17
- package/scripts/precompute_dashboard_stats.py +10 -5
- package/scripts/project_stats_json.py +298 -11
- package/scripts/reddit_browser.py +24 -3
- package/scripts/reddit_tools.py +30 -14
- package/scripts/regenerate_ig_plists.py +98 -41
- package/scripts/reply_db.py +6 -0
- package/scripts/scan_dm_candidates.py +19 -1
- package/scripts/score_twitter_candidates.py +8 -0
- package/scripts/top_performers.py +56 -2
- package/scripts/twitter_account.py +76 -0
- package/scripts/twitter_cycle_helper.py +24 -8
- package/scripts/twitter_gen_links.py +22 -2
- package/scripts/twitter_post_plan.py +12 -0
- package/scripts/update_stats.py +162 -80
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +6 -1
- package/skill/dm-outreach-twitter.sh +8 -8
- package/skill/engage-twitter.sh +64 -157
- package/skill/lib/twitter-backend.sh +57 -83
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/run-instagram-daily.sh +11 -3
- package/skill/run-twitter-cycle.sh +76 -110
- package/skill/styles.sh +74 -3
package/bin/server.js
CHANGED
|
@@ -385,11 +385,19 @@ function getPool() {
|
|
|
385
385
|
if (_pool) return _pool;
|
|
386
386
|
const dbUrl = getDbUrl();
|
|
387
387
|
if (!dbUrl) return null;
|
|
388
|
+
// Pool sized for the per-project breakdown load pattern: 8 pg-backed
|
|
389
|
+
// endpoints (views/upvotes/comments/clicks/posts/bookings/cost + funnel
|
|
390
|
+
// metadata) firing for 2-3 concurrent projects = ~24 simultaneous queries,
|
|
391
|
+
// on top of normal page load. max:5 caused ~219k connection timeouts in
|
|
392
|
+
// skill/logs/launchd-dashboard-stderr.log on 2026-05-19 because every
|
|
393
|
+
// pg-backed request started failing at the 10s connectionTimeoutMillis cap
|
|
394
|
+
// (blank Get Started card, empty per-project rows). Neon free tier allows
|
|
395
|
+
// 100 concurrent connections per project, so 25 is well within budget.
|
|
388
396
|
_pool = new Pool({
|
|
389
397
|
connectionString: dbUrl,
|
|
390
|
-
max:
|
|
398
|
+
max: 25,
|
|
391
399
|
idleTimeoutMillis: 30000,
|
|
392
|
-
connectionTimeoutMillis:
|
|
400
|
+
connectionTimeoutMillis: 30000,
|
|
393
401
|
});
|
|
394
402
|
_pool.on('error', (err) => {
|
|
395
403
|
console.error('[pg.Pool] idle client error:', err.message);
|
|
@@ -416,9 +424,9 @@ function getBookingsPool() {
|
|
|
416
424
|
if (!dbUrl) return null;
|
|
417
425
|
_bookingsPool = new Pool({
|
|
418
426
|
connectionString: dbUrl,
|
|
419
|
-
max:
|
|
427
|
+
max: 10,
|
|
420
428
|
idleTimeoutMillis: 30000,
|
|
421
|
-
connectionTimeoutMillis:
|
|
429
|
+
connectionTimeoutMillis: 30000,
|
|
422
430
|
});
|
|
423
431
|
_bookingsPool.on('error', (err) => {
|
|
424
432
|
console.error('[bookings pg.Pool] idle client error:', err.message);
|
|
@@ -1102,6 +1110,20 @@ function parseTwitterBatchIdMs(batchId) {
|
|
|
1102
1110
|
return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
|
|
1103
1111
|
}
|
|
1104
1112
|
|
|
1113
|
+
// Parse a reddit cycle batch_id (`rdcycle-YYYYMMDD-HHMMSS`) into epoch ms.
|
|
1114
|
+
// Mirrors parseTwitterBatchIdMs. Used to attribute reddit_candidates +
|
|
1115
|
+
// posts back to the cycle that owns them, so styles_used / posted counts
|
|
1116
|
+
// reflect THIS run's work, not whatever concurrent reddit cycles posted
|
|
1117
|
+
// during the same wall-clock window (the prior window-based approach
|
|
1118
|
+
// double-counted styles on overlapping 22-min cycles).
|
|
1119
|
+
function parseRedditBatchIdMs(batchId) {
|
|
1120
|
+
if (!batchId) return NaN;
|
|
1121
|
+
const m = batchId.match(/^rdcycle-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
|
1122
|
+
if (!m) return NaN;
|
|
1123
|
+
const [, y, mo, d, hh, mm, ss] = m;
|
|
1124
|
+
return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1105
1127
|
async function enrichPostCommentsTwitterRuns(runs) {
|
|
1106
1128
|
const txRuns = runs.filter(r =>
|
|
1107
1129
|
r.job_type === 'post-comments' && r.platform_key === 'twitter'
|
|
@@ -1142,7 +1164,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1142
1164
|
const candidateRows = await pq(
|
|
1143
1165
|
"SELECT discovered_at, posted_at, t1_checked_at, drafted_at, " +
|
|
1144
1166
|
" (draft_reply_text IS NOT NULL) AS has_draft, status, batch_id, " +
|
|
1145
|
-
" matched_project " +
|
|
1167
|
+
" matched_project, tweet_url " +
|
|
1146
1168
|
"FROM twitter_candidates " +
|
|
1147
1169
|
"WHERE discovered_at >= $1::timestamp OR posted_at >= $1::timestamp OR t1_checked_at >= $1::timestamp OR status='pending'",
|
|
1148
1170
|
[since]
|
|
@@ -1176,12 +1198,23 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1176
1198
|
" )"
|
|
1177
1199
|
);
|
|
1178
1200
|
const salvageableNow = (salvageableRow && salvageableRow[0]) ? salvageableRow[0].n : 0;
|
|
1201
|
+
// Bulk-fetch twitter/x posts in window to compute per-run style breakdown.
|
|
1202
|
+
const twitterPostRows = await pq(
|
|
1203
|
+
"SELECT posted_at, thread_url, engagement_style FROM posts " +
|
|
1204
|
+
"WHERE platform IN ('twitter', 'x') AND posted_at >= $1::timestamp AND engagement_style IS NOT NULL",
|
|
1205
|
+
[since]
|
|
1206
|
+
) || [];
|
|
1179
1207
|
|
|
1180
1208
|
const toMs = (d) => {
|
|
1181
1209
|
if (!d) return null;
|
|
1182
1210
|
const dt = d instanceof Date ? d : new Date(d);
|
|
1183
1211
|
return dt.getTime();
|
|
1184
1212
|
};
|
|
1213
|
+
const twitterPostNorm = twitterPostRows.map(r => ({
|
|
1214
|
+
postedMs: toMs(r.posted_at),
|
|
1215
|
+
threadUrl: r.thread_url || '',
|
|
1216
|
+
style: r.engagement_style || '',
|
|
1217
|
+
}));
|
|
1185
1218
|
const searchNorm = searchRows.map(r => ({
|
|
1186
1219
|
ms: toMs(r.ran_at),
|
|
1187
1220
|
found: r.tweets_found || 0,
|
|
@@ -1207,6 +1240,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1207
1240
|
status: r.status,
|
|
1208
1241
|
batch_id: r.batch_id || '',
|
|
1209
1242
|
matched_project: r.matched_project || '',
|
|
1243
|
+
tweet_url: r.tweet_url || '',
|
|
1210
1244
|
};
|
|
1211
1245
|
});
|
|
1212
1246
|
|
|
@@ -1383,6 +1417,30 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1383
1417
|
}
|
|
1384
1418
|
}
|
|
1385
1419
|
}
|
|
1420
|
+
// Style counting: scope to the URLs this batch posted, not the time window.
|
|
1421
|
+
// Time-window approach captured concurrent cycles during long-running cycles.
|
|
1422
|
+
const batchPostedUrls = new Set();
|
|
1423
|
+
for (const c of candNorm) {
|
|
1424
|
+
if (ownBatchId && c.batch_id === ownBatchId && c.status === 'posted' && c.tweet_url) {
|
|
1425
|
+
batchPostedUrls.add(c.tweet_url);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
const stylesMapTx = {};
|
|
1429
|
+
for (const p of twitterPostNorm) {
|
|
1430
|
+
if (!p.threadUrl || !batchPostedUrls.has(p.threadUrl)) continue;
|
|
1431
|
+
stylesMapTx[p.style] = (stylesMapTx[p.style] || 0) + 1;
|
|
1432
|
+
}
|
|
1433
|
+
// Fall back to time-window if batch URL matching found nothing (e.g. old
|
|
1434
|
+
// rows before tweet_url was added to the SELECT, or cycle with no ownBatchId).
|
|
1435
|
+
if (!Object.keys(stylesMapTx).length && !ownBatchId) {
|
|
1436
|
+
for (const p of twitterPostNorm) {
|
|
1437
|
+
if (p.postedMs == null || p.postedMs < startMs || p.postedMs > endMs) continue;
|
|
1438
|
+
stylesMapTx[p.style] = (stylesMapTx[p.style] || 0) + 1;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
const stylesUsedTx = Object.entries(stylesMapTx)
|
|
1442
|
+
.sort(function (a, b) { return b[1] - a[1]; })
|
|
1443
|
+
.map(function (e) { return e[0] + '(' + e[1] + ')'; });
|
|
1386
1444
|
const prior = run.result || {};
|
|
1387
1445
|
const priorDiscover = (prior.discover && typeof prior.discover === 'object') ? prior.discover : {};
|
|
1388
1446
|
run.result = {
|
|
@@ -1421,6 +1479,7 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1421
1479
|
// pill row so the operator can see at a glance which projects consumed
|
|
1422
1480
|
// the cycle, even when posted=0. Mirrors enrichPostCommentsRedditRuns.
|
|
1423
1481
|
projects_worked: projectsList,
|
|
1482
|
+
styles_used: stylesUsedTx,
|
|
1424
1483
|
cost_usd: prior.cost_usd || 0,
|
|
1425
1484
|
failed: prior.failed || 0,
|
|
1426
1485
|
failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
|
|
@@ -1478,7 +1537,8 @@ async function enrichPostCommentsRedditRuns(runs) {
|
|
|
1478
1537
|
const since = new Date(oldestMs - 2 * 60 * 1000).toISOString();
|
|
1479
1538
|
const candidateRows = await pq(
|
|
1480
1539
|
"SELECT discovered_at, posted_at, last_attempt_at, t1_checked_at, drafted_at, " +
|
|
1481
|
-
" attempt_count, (draft_text IS NOT NULL) AS has_draft, status, batch_id " +
|
|
1540
|
+
" attempt_count, (draft_text IS NOT NULL) AS has_draft, status, batch_id, " +
|
|
1541
|
+
" post_id " +
|
|
1482
1542
|
"FROM reddit_candidates " +
|
|
1483
1543
|
"WHERE discovered_at >= $1::timestamp " +
|
|
1484
1544
|
" OR posted_at >= $1::timestamp " +
|
|
@@ -1504,12 +1564,26 @@ async function enrichPostCommentsRedditRuns(runs) {
|
|
|
1504
1564
|
" )"
|
|
1505
1565
|
);
|
|
1506
1566
|
const salvageableNow = (salvageableRow && salvageableRow[0]) ? salvageableRow[0].n : 0;
|
|
1567
|
+
// Bulk-fetch reddit posts in window to compute per-run style breakdown.
|
|
1568
|
+
// posts.id is included so the enricher can scope styles to "posts whose id
|
|
1569
|
+
// appears as reddit_candidates.post_id for this run's own batch_id",
|
|
1570
|
+
// mirroring the Twitter batch-scoped style attribution.
|
|
1571
|
+
const redditPostRows = await pq(
|
|
1572
|
+
"SELECT id, posted_at, engagement_style FROM posts " +
|
|
1573
|
+
"WHERE platform = 'reddit' AND posted_at >= $1::timestamp AND engagement_style IS NOT NULL",
|
|
1574
|
+
[since]
|
|
1575
|
+
) || [];
|
|
1507
1576
|
|
|
1508
1577
|
const toMs = (d) => {
|
|
1509
1578
|
if (!d) return null;
|
|
1510
1579
|
const dt = d instanceof Date ? d : new Date(d);
|
|
1511
1580
|
return dt.getTime();
|
|
1512
1581
|
};
|
|
1582
|
+
const redditPostNorm = redditPostRows.map(r => ({
|
|
1583
|
+
id: r.id,
|
|
1584
|
+
postedMs: toMs(r.posted_at),
|
|
1585
|
+
style: r.engagement_style || '',
|
|
1586
|
+
}));
|
|
1513
1587
|
// exitMs = the moment the row left 'pending'. Null for rows still pending.
|
|
1514
1588
|
// posted -> posted_at
|
|
1515
1589
|
// failed -> last_attempt_at (set by _db_mark_candidate_attempt + html_locked)
|
|
@@ -1539,6 +1613,7 @@ async function enrichPostCommentsRedditRuns(runs) {
|
|
|
1539
1613
|
exitMs,
|
|
1540
1614
|
status: r.status,
|
|
1541
1615
|
batch_id: r.batch_id || '',
|
|
1616
|
+
post_id: r.post_id || null,
|
|
1542
1617
|
};
|
|
1543
1618
|
});
|
|
1544
1619
|
// Filename carries the run start: run-reddit-search-YYYY-MM-DD_HHMMSS.log
|
|
@@ -1847,6 +1922,57 @@ async function enrichPostCommentsRedditRuns(runs) {
|
|
|
1847
1922
|
+ queueDrainedExpired + queueDrainedSkipped;
|
|
1848
1923
|
|
|
1849
1924
|
const dropped = Math.max(0, raw - passed);
|
|
1925
|
+
// Style counting: scope to the post_ids THIS batch posted, not the time
|
|
1926
|
+
// window. Mirrors the Twitter fix (server.js ~line 1408). Window-based
|
|
1927
|
+
// attribution double-counted styles during long-running cycles because
|
|
1928
|
+
// reddit-search overlaps every 15 min and salvage/discover lanes from
|
|
1929
|
+
// OTHER projects post inside the same wall-clock window.
|
|
1930
|
+
//
|
|
1931
|
+
// Derive ownBatchId by matching rdcycle-YYYYMMDD-HHMMSS to run.started_at
|
|
1932
|
+
// within ±10s. Fall back to the cycle log header `Cycle batch_id=...`
|
|
1933
|
+
// (emitted by run-reddit-search.sh:191) when no candidate batch matches,
|
|
1934
|
+
// so cycles that scored 0 raw → 0 candidates still attribute their styles.
|
|
1935
|
+
let ownBatchId = null;
|
|
1936
|
+
let ownBatchDelta = Infinity;
|
|
1937
|
+
for (const c of candNorm) {
|
|
1938
|
+
if (!c.batch_id) continue;
|
|
1939
|
+
const bms = parseRedditBatchIdMs(c.batch_id);
|
|
1940
|
+
if (!Number.isFinite(bms)) continue;
|
|
1941
|
+
const delta = Math.abs(bms - startMs);
|
|
1942
|
+
if (delta > 10 * 1000) continue;
|
|
1943
|
+
if (delta < ownBatchDelta) { ownBatchDelta = delta; ownBatchId = c.batch_id; }
|
|
1944
|
+
}
|
|
1945
|
+
if (!ownBatchId) {
|
|
1946
|
+
const logHeader = body.match(/Cycle batch_id=(rdcycle-\d{8}-\d{6})/);
|
|
1947
|
+
if (logHeader) ownBatchId = logHeader[1];
|
|
1948
|
+
}
|
|
1949
|
+
// Build a Set of posts.id values that THIS batch's reddit_candidates
|
|
1950
|
+
// rows have stamped as their post_id (the posts row created by
|
|
1951
|
+
// post_reddit.py when status flipped to 'posted').
|
|
1952
|
+
const batchPostedIds = new Set();
|
|
1953
|
+
for (const c of candNorm) {
|
|
1954
|
+
if (ownBatchId && c.batch_id === ownBatchId
|
|
1955
|
+
&& c.status === 'posted' && c.post_id != null) {
|
|
1956
|
+
batchPostedIds.add(c.post_id);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
const stylesMapRd = {};
|
|
1960
|
+
for (const p of redditPostNorm) {
|
|
1961
|
+
if (p.id == null || !batchPostedIds.has(p.id)) continue;
|
|
1962
|
+
stylesMapRd[p.style] = (stylesMapRd[p.style] || 0) + 1;
|
|
1963
|
+
}
|
|
1964
|
+
// Fall back to time-window when batch-scoped lookup found nothing AND we
|
|
1965
|
+
// failed to identify ownBatchId (very old rows, or cycle log missing the
|
|
1966
|
+
// header). Preserves visibility for runs with no batch correlation.
|
|
1967
|
+
if (!Object.keys(stylesMapRd).length && !ownBatchId) {
|
|
1968
|
+
for (const p of redditPostNorm) {
|
|
1969
|
+
if (p.postedMs == null || p.postedMs < startMs || p.postedMs > endMs) continue;
|
|
1970
|
+
stylesMapRd[p.style] = (stylesMapRd[p.style] || 0) + 1;
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
const stylesUsedRd = Object.entries(stylesMapRd)
|
|
1974
|
+
.sort(function (a, b) { return b[1] - a[1]; })
|
|
1975
|
+
.map(function (e) { return e[0] + '(' + e[1] + ')'; });
|
|
1850
1976
|
const prior = run.result || {};
|
|
1851
1977
|
// Trust the per-iter rollup `phase=post posted=N` over the bare POSTED:
|
|
1852
1978
|
// grep when both exist (POSTED: can fire mid-retry). Fall back to the
|
|
@@ -1881,6 +2007,11 @@ async function enrichPostCommentsRedditRuns(runs) {
|
|
|
1881
2007
|
// end of the dashboard pill row so the operator can see at a glance which
|
|
1882
2008
|
// project(s) consumed the cycle (often 2 distinct: salvage lane + discover).
|
|
1883
2009
|
projects_worked: projectsList,
|
|
2010
|
+
styles_used: stylesUsedRd,
|
|
2011
|
+
// Exposed for parity with Twitter's enricher result; lets the dashboard
|
|
2012
|
+
// (or `/api/...`) show which rdcycle batch this run "owns" for debugging
|
|
2013
|
+
// batch-scoped attribution misses.
|
|
2014
|
+
own_batch_id: ownBatchId,
|
|
1884
2015
|
// Ripen phase (5-min delta gate, scripts/ripen_reddit_plan.py). Reflects
|
|
1885
2016
|
// the per-run sum across all iterations that reached the ripen step.
|
|
1886
2017
|
// ripen_iters counts iterations where the [ripen] summary marker fired
|
|
@@ -4155,7 +4286,7 @@ async function handleApi(req, res) {
|
|
|
4155
4286
|
// Project is case-sensitive (stored as 'Assrt', 'Cyrano', 'fazm', etc.).
|
|
4156
4287
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4157
4288
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4158
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4289
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4159
4290
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4160
4291
|
// Non-admin clients can only see projects in their claim. Reject if the
|
|
4161
4292
|
// requested project isn't allowed, and force-filter the default "all" view.
|
|
@@ -4207,18 +4338,23 @@ async function handleApi(req, res) {
|
|
|
4207
4338
|
"ELSE COALESCE(upvotes,0) END), 0)::int AS upvotes_discounted, " +
|
|
4208
4339
|
"COALESCE(SUM(comments_count), 0)::int AS comments, " +
|
|
4209
4340
|
"COALESCE(SUM(views) FILTER (WHERE LOWER(platform) NOT IN ('moltbook', 'github', 'github_issues')), 0)::int AS views, " +
|
|
4210
|
-
// post_clicks:
|
|
4211
|
-
// minted for these posts (post_id-keyed). Reply-keyed
|
|
4212
|
-
// excluded so we don't double-count engagement on replies
|
|
4213
|
-
// off someone else's thread.
|
|
4341
|
+
// post_clicks: bot-filtered click events from post_link_clicks for
|
|
4342
|
+
// short links minted for these posts (post_id-keyed). Reply-keyed
|
|
4343
|
+
// clicks are excluded so we don't double-count engagement on replies
|
|
4344
|
+
// that hang off someone else's thread. Source matches the picker
|
|
4345
|
+
// (engagement_styles._fetch_style_stats) and top_performers.SCORE_SQL.
|
|
4214
4346
|
"COALESCE(SUM(pl.total_clicks), 0)::int AS post_clicks, " +
|
|
4215
4347
|
// Intent dimension (is_recommendation) is independent of tone (engagement_style).
|
|
4216
4348
|
// This sum tells us "of N posts in this tone, how many carried a project mention".
|
|
4217
4349
|
"COALESCE(SUM(CASE WHEN is_recommendation THEN 1 ELSE 0 END), 0)::int AS recommendations " +
|
|
4218
4350
|
"FROM posts " +
|
|
4219
4351
|
"LEFT JOIN (" +
|
|
4220
|
-
"SELECT post_id,
|
|
4221
|
-
"FROM post_links
|
|
4352
|
+
"SELECT pl2.post_id, COUNT(plc.id)::int AS total_clicks " +
|
|
4353
|
+
"FROM post_links pl2 " +
|
|
4354
|
+
"LEFT JOIN post_link_clicks plc " +
|
|
4355
|
+
"ON plc.code = pl2.code AND plc.is_bot = false " +
|
|
4356
|
+
"WHERE pl2.post_id IS NOT NULL " +
|
|
4357
|
+
"GROUP BY pl2.post_id" +
|
|
4222
4358
|
") pl ON pl.post_id = posts.id " +
|
|
4223
4359
|
"WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
|
|
4224
4360
|
"AND our_content <> '(mention - no original post)' " +
|
|
@@ -4246,9 +4382,10 @@ async function handleApi(req, res) {
|
|
|
4246
4382
|
}
|
|
4247
4383
|
|
|
4248
4384
|
// GET /api/cohort/stats - posts bucketed into 4 score cohorts over a trailing window.
|
|
4249
|
-
// Score formula matches top_performers.py SCORE_SQL:
|
|
4250
|
-
// score = comments_count*3 + upvotes (Reddit/Moltbook: -1 to strip OP self-upvote)
|
|
4251
|
-
//
|
|
4385
|
+
// Score formula matches top_performers.py SCORE_SQL and engagement_styles.py picker:
|
|
4386
|
+
// score = clicks*10 + comments_count*3 + upvotes (Reddit/Moltbook: -1 to strip OP self-upvote)
|
|
4387
|
+
// clicks = COUNT(post_link_clicks WHERE is_bot=false) per post (bot-filtered).
|
|
4388
|
+
// Cohorts: dead=0, low=1-9, mid=10-29, high=30+ (rebucketed for the click-weighted score).
|
|
4252
4389
|
// Honors the same window/platform/project filters as the rest of the Stats tab.
|
|
4253
4390
|
if (p === '/api/cohort/stats' && req.method === 'GET') {
|
|
4254
4391
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -4260,7 +4397,7 @@ async function handleApi(req, res) {
|
|
|
4260
4397
|
if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
|
|
4261
4398
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4262
4399
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4263
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4400
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4264
4401
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4265
4402
|
const cohortPc = auth.projectClause(req.user, 'project_name', project || null);
|
|
4266
4403
|
if (!cohortPc.ok) return json(res, { windowHours, platform: platform || 'all', project: project || 'all', rows: [], totalPosts: 0 });
|
|
@@ -4275,17 +4412,27 @@ async function handleApi(req, res) {
|
|
|
4275
4412
|
: '';
|
|
4276
4413
|
const projectFilter = cohortPc.clause ? cohortPc.clause + ' '
|
|
4277
4414
|
: (project ? "AND project_name = '" + project.replace(/'/g, "''") + "' " : '');
|
|
4278
|
-
// Score expression (must stay aligned with scripts/top_performers.SCORE_SQL
|
|
4415
|
+
// Score expression (must stay aligned with scripts/top_performers.SCORE_SQL
|
|
4416
|
+
// and scripts/engagement_styles.py picker). clicks weighted ×10 because a
|
|
4417
|
+
// real human click outvalues 10 likes of vibes. Clicks come from a
|
|
4418
|
+
// bot-filtered subquery against post_link_clicks (matching the picker).
|
|
4279
4419
|
const scoreExpr =
|
|
4280
|
-
"(COALESCE(
|
|
4420
|
+
"(COALESCE(clicks, 0) * 10 + " +
|
|
4421
|
+
"COALESCE(comments_count,0) * 3 + " +
|
|
4281
4422
|
"CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
|
|
4282
4423
|
"THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
|
|
4283
4424
|
"ELSE COALESCE(upvotes,0) END)";
|
|
4425
|
+
// Cohort bands rescaled for the click-weighted score so most posts don't
|
|
4426
|
+
// collapse into 'high' (a single click already adds 10). Empirically:
|
|
4427
|
+
// dead = 0 no engagement at all
|
|
4428
|
+
// low = 1-9 some upvotes/comments OR no clicks
|
|
4429
|
+
// mid = 10-29 a click OR strong comment/upvote
|
|
4430
|
+
// high = 30+ clicks + discussion combined
|
|
4284
4431
|
const cohortExpr =
|
|
4285
4432
|
"CASE " +
|
|
4286
4433
|
"WHEN " + scoreExpr + " = 0 THEN 'dead' " +
|
|
4287
|
-
"WHEN " + scoreExpr + " BETWEEN 1 AND
|
|
4288
|
-
"WHEN " + scoreExpr + " BETWEEN
|
|
4434
|
+
"WHEN " + scoreExpr + " BETWEEN 1 AND 9 THEN 'low' " +
|
|
4435
|
+
"WHEN " + scoreExpr + " BETWEEN 10 AND 29 THEN 'mid' " +
|
|
4289
4436
|
"ELSE 'high' END";
|
|
4290
4437
|
// Views excluded from moltbook/github (no public view counter on those
|
|
4291
4438
|
// platforms); use FILTER so the views range only reflects platforms that
|
|
@@ -4314,11 +4461,20 @@ async function handleApi(req, res) {
|
|
|
4314
4461
|
"MAX(COALESCE(views,0)) " + viewsFilter + " AS max_views, " +
|
|
4315
4462
|
"(AVG(COALESCE(views,0)) " + viewsFilter + ")::numeric(10,0) AS avg_views " +
|
|
4316
4463
|
"FROM (" +
|
|
4317
|
-
"SELECT platform, " + upvotesNetExpr + " AS upvotes_net, " +
|
|
4464
|
+
"SELECT posts.platform, " + upvotesNetExpr + " AS upvotes_net, " +
|
|
4465
|
+
"COALESCE(pl.total_clicks, 0) AS clicks, " +
|
|
4318
4466
|
"comments_count, views, " +
|
|
4319
4467
|
scoreExpr + " AS score, " +
|
|
4320
4468
|
cohortExpr + " AS cohort " +
|
|
4321
4469
|
"FROM posts " +
|
|
4470
|
+
"LEFT JOIN (" +
|
|
4471
|
+
"SELECT pl2.post_id, COUNT(plc.id)::int AS total_clicks " +
|
|
4472
|
+
"FROM post_links pl2 " +
|
|
4473
|
+
"LEFT JOIN post_link_clicks plc " +
|
|
4474
|
+
"ON plc.code = pl2.code AND plc.is_bot = false " +
|
|
4475
|
+
"WHERE pl2.post_id IS NOT NULL " +
|
|
4476
|
+
"GROUP BY pl2.post_id" +
|
|
4477
|
+
") pl ON pl.post_id = posts.id " +
|
|
4322
4478
|
"WHERE posted_at >= NOW() - INTERVAL '" + windowHours + " hours' " +
|
|
4323
4479
|
"AND upvotes IS NOT NULL " +
|
|
4324
4480
|
"AND our_content <> '(mention - no original post)' " +
|
|
@@ -4507,7 +4663,7 @@ async function handleApi(req, res) {
|
|
|
4507
4663
|
if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
|
|
4508
4664
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4509
4665
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4510
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4666
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4511
4667
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4512
4668
|
const cacheKey = days + '|' + platform + '|' + project;
|
|
4513
4669
|
const cached = cache.get(cacheKey);
|
|
@@ -4587,7 +4743,7 @@ async function handleApi(req, res) {
|
|
|
4587
4743
|
if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
|
|
4588
4744
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4589
4745
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4590
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4746
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4591
4747
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4592
4748
|
const cacheKey = days + '|' + platform + '|' + project;
|
|
4593
4749
|
const cached = clicksPerDayCache.get(cacheKey);
|
|
@@ -4639,7 +4795,7 @@ async function handleApi(req, res) {
|
|
|
4639
4795
|
if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
|
|
4640
4796
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4641
4797
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4642
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4798
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4643
4799
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4644
4800
|
const cacheKey = days + '|' + platform + '|' + project;
|
|
4645
4801
|
const cached = postsPerDayCache.get(cacheKey);
|
|
@@ -4690,7 +4846,7 @@ async function handleApi(req, res) {
|
|
|
4690
4846
|
const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
|
|
4691
4847
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4692
4848
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4693
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4849
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4694
4850
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4695
4851
|
const cacheKey = days + '|' + project;
|
|
4696
4852
|
const cached = bookingsPerDayCache.get(cacheKey);
|
|
@@ -4730,7 +4886,7 @@ async function handleApi(req, res) {
|
|
|
4730
4886
|
const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '30', 10) || 30));
|
|
4731
4887
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4732
4888
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4733
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
4889
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4734
4890
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4735
4891
|
const cacheKey = days + '|' + project;
|
|
4736
4892
|
const cached = funnelPerDayCache.get(cacheKey);
|
|
@@ -4921,7 +5077,7 @@ async function handleApi(req, res) {
|
|
|
4921
5077
|
const days = Math.max(1, Math.min(365, parseInt(url.searchParams.get('days') || '30', 10) || 30));
|
|
4922
5078
|
const rawProject = (url.searchParams.get('project') || '').trim();
|
|
4923
5079
|
const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
|
|
4924
|
-
const projectOk = project === '' || /^[A-Za-z0-9_\-]{1,64}$/.test(project);
|
|
5080
|
+
const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
|
|
4925
5081
|
if (!projectOk) return json(res, { error: 'invalid project' }, 400);
|
|
4926
5082
|
const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email']);
|
|
4927
5083
|
let rawPlat = String(url.searchParams.get('platform') || '').toLowerCase().trim();
|
|
@@ -5867,36 +6023,44 @@ async function handleApi(req, res) {
|
|
|
5867
6023
|
// slow (~15-30s), so we cache for 10 min and dedupe concurrent callers.
|
|
5868
6024
|
// A launchd timer (com.m13v.social-precompute-stats) also writes fresh
|
|
5869
6025
|
// snapshots to skill/cache/funnel_stats_<N>d.json so cold starts are instant.
|
|
6026
|
+
//
|
|
6027
|
+
// Platform filter handling:
|
|
6028
|
+
// !platform: read the all-platform snapshot from disk (~instant).
|
|
6029
|
+
// platform set: read the snapshot for funnel cells (pageviews / signups /
|
|
6030
|
+
// bookings / amplitude are platform-independent since they measure on-
|
|
6031
|
+
// site behavior, not which social platform drove the visitor) AND spawn
|
|
6032
|
+
// python with --posts-only --platform=X to recompute ONLY the per-project
|
|
6033
|
+
// posts.* engagement counters (~1.5s instead of ~30s+ because the
|
|
6034
|
+
// PostHog/Amplitude/bookings batch is skipped). Merge the two into one
|
|
6035
|
+
// payload so the dashboard sees platform-scoped engagement on the same
|
|
6036
|
+
// row as the all-source funnel cells.
|
|
5870
6037
|
if (p === '/api/funnel/stats' && req.method === 'GET') {
|
|
5871
6038
|
const url = new URL(req.url, 'http://localhost');
|
|
5872
6039
|
const days = Math.max(1, Math.min(90, parseInt(url.searchParams.get('days') || '1', 10) || 1));
|
|
5873
|
-
const
|
|
6040
|
+
const rawPlatform = (url.searchParams.get('platform') || '').trim().toLowerCase();
|
|
6041
|
+
const platform = (rawPlatform === '' || rawPlatform === 'all') ? '' :
|
|
6042
|
+
(rawPlatform === 'x' ? 'twitter' : rawPlatform);
|
|
6043
|
+
const platformOk = platform === '' || /^[a-z0-9_]{1,32}$/.test(platform);
|
|
6044
|
+
if (!platformOk) return json(res, { error: 'invalid platform' }, 400);
|
|
6045
|
+
const cacheKey = days + '|' + platform;
|
|
6046
|
+
const entry = funnelStatsCache.get(cacheKey);
|
|
5874
6047
|
const TTL_MS = 600000;
|
|
5875
6048
|
if (entry && entry.value && Date.now() - entry.at < TTL_MS) {
|
|
5876
|
-
return json(res, scopeFunnelStatsPayload({ days, ...entry.value, cachedAt: entry.at }, req.user));
|
|
5877
|
-
}
|
|
5878
|
-
const snap = await readSnapshotCached(`funnel_stats_${days}d.json`);
|
|
5879
|
-
if (snap && snap.value && !snap.value.error) {
|
|
5880
|
-
// Warm the in-memory cache so subsequent hits skip the disk read too.
|
|
5881
|
-
funnelStatsCache.set(days, { at: snap.at, value: snap.value });
|
|
5882
|
-
return json(res, scopeFunnelStatsPayload({ ...snap.value, cachedAt: snap.at }, req.user));
|
|
6049
|
+
return json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...entry.value, cachedAt: entry.at }, req.user));
|
|
5883
6050
|
}
|
|
5884
6051
|
if (entry && entry.pending) {
|
|
5885
|
-
entry.pending.then(val => json(res, scopeFunnelStatsPayload({ days, ...val, cachedAt: Date.now() }, req.user)))
|
|
6052
|
+
entry.pending.then(val => json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...val, cachedAt: Date.now() }, req.user)))
|
|
5886
6053
|
.catch(err => json(res, { error: String(err && err.message || err) }, 500));
|
|
5887
6054
|
return;
|
|
5888
6055
|
}
|
|
5889
|
-
// Cloud Run has no python runtime and no PostHog creds; only the
|
|
5890
|
-
// operator's local server can run the live pipeline. Return whatever
|
|
5891
|
-
// we've got (empty snapshot if nothing) rather than hanging.
|
|
5892
6056
|
if (auth.CLIENT_MODE) {
|
|
5893
|
-
return json(res, { days, error: 'snapshot_missing', cachedAt: null }, 503);
|
|
6057
|
+
return json(res, { days, platform: platform || 'all', error: 'snapshot_missing', cachedAt: null }, 503);
|
|
5894
6058
|
}
|
|
6059
|
+
|
|
5895
6060
|
const scriptPath = path.join(DEST, 'scripts', 'project_stats_json.py');
|
|
5896
|
-
const
|
|
5897
|
-
const
|
|
5898
|
-
|
|
5899
|
-
});
|
|
6061
|
+
const runPython = (extraArgs) => new Promise((resolve, reject) => {
|
|
6062
|
+
const args = [scriptPath, '--days', String(days), ...extraArgs];
|
|
6063
|
+
const child = spawn('python3', args, { env: process.env, cwd: DEST });
|
|
5900
6064
|
let out = '', err = '';
|
|
5901
6065
|
child.stdout.on('data', d => out += d);
|
|
5902
6066
|
child.stderr.on('data', d => err += d);
|
|
@@ -5906,12 +6070,69 @@ async function handleApi(req, res) {
|
|
|
5906
6070
|
try { resolve(JSON.parse(out)); } catch (e) { reject(e); }
|
|
5907
6071
|
});
|
|
5908
6072
|
});
|
|
5909
|
-
|
|
6073
|
+
|
|
6074
|
+
let pending;
|
|
6075
|
+
if (platform) {
|
|
6076
|
+
// Fast platform-filtered path: snapshot (funnel cells) + posts-only overlay.
|
|
6077
|
+
// Falls back to the slow full pipeline if no all-platform snapshot exists.
|
|
6078
|
+
pending = (async () => {
|
|
6079
|
+
// Use a 24h freshness window for the overlay base: the snapshot only
|
|
6080
|
+
// contributes the funnel cells (pageviews / signups / bookings /
|
|
6081
|
+
// amplitude / SEO pages), all of which move on a daily-ish timescale.
|
|
6082
|
+
// The posts.* counters (which DO move every few minutes) are
|
|
6083
|
+
// recomputed live via --posts-only, so a stale funnel-cell base is
|
|
6084
|
+
// safe and avoids falling back to the 30s+ full pipeline whenever
|
|
6085
|
+
// the precompute job lags >15min.
|
|
6086
|
+
const STALE_OK = 24 * 60 * 60 * 1000;
|
|
6087
|
+
const snap = await readSnapshotCached(`funnel_stats_${days}d.json`, STALE_OK);
|
|
6088
|
+
if (!snap || !snap.value || snap.value.error || !Array.isArray(snap.value.projects)) {
|
|
6089
|
+
return runPython(['--platform', platform]);
|
|
6090
|
+
}
|
|
6091
|
+
const overlay = await runPython(['--platform', platform, '--posts-only']);
|
|
6092
|
+
const overlayByName = new Map();
|
|
6093
|
+
for (const op of overlay.projects || []) {
|
|
6094
|
+
if (op && op.name) overlayByName.set(op.name, op);
|
|
6095
|
+
}
|
|
6096
|
+
const mergedProjects = snap.value.projects.map(snapP => {
|
|
6097
|
+
if (!snapP || !snapP.name) return snapP;
|
|
6098
|
+
const op = overlayByName.get(snapP.name);
|
|
6099
|
+
if (!op || op.error || !op.posts) return snapP;
|
|
6100
|
+
// Replace ONLY the posts.* counters from the overlay; everything
|
|
6101
|
+
// else (funnel, posthog, bookings, seo, platforms, analytics flags)
|
|
6102
|
+
// stays at the snapshot's all-platform value because those metrics
|
|
6103
|
+
// don't move with the social-platform filter.
|
|
6104
|
+
return { ...snapP, posts: { ...(snapP.posts || {}), ...op.posts } };
|
|
6105
|
+
});
|
|
6106
|
+
return {
|
|
6107
|
+
generated_at: overlay.generated_at || snap.value.generated_at,
|
|
6108
|
+
days,
|
|
6109
|
+
platform,
|
|
6110
|
+
posts_only_overlay: true,
|
|
6111
|
+
projects: mergedProjects,
|
|
6112
|
+
overall: snap.value.overall || null,
|
|
6113
|
+
};
|
|
6114
|
+
})();
|
|
6115
|
+
} else {
|
|
6116
|
+
// Prefer a fresh snapshot. If none, accept a stale-but-recent one
|
|
6117
|
+
// (<24h) to avoid blocking the user behind a 30s+ live python run
|
|
6118
|
+
// every time the precompute job lags. The 5-min precompute timer
|
|
6119
|
+
// cycles through all five windows (1d/7d/14d/30d/90d) so any one
|
|
6120
|
+
// can be 20-30min old at random.
|
|
6121
|
+
const STALE_OK = 24 * 60 * 60 * 1000;
|
|
6122
|
+
const snap = await readSnapshotCached(`funnel_stats_${days}d.json`, STALE_OK);
|
|
6123
|
+
if (snap && snap.value && !snap.value.error) {
|
|
6124
|
+
funnelStatsCache.set(cacheKey, { at: snap.at, value: snap.value });
|
|
6125
|
+
return json(res, scopeFunnelStatsPayload({ ...snap.value, cachedAt: snap.at }, req.user));
|
|
6126
|
+
}
|
|
6127
|
+
pending = runPython([]);
|
|
6128
|
+
}
|
|
6129
|
+
|
|
6130
|
+
funnelStatsCache.set(cacheKey, { at: Date.now(), pending });
|
|
5910
6131
|
pending.then(val => {
|
|
5911
|
-
funnelStatsCache.set(
|
|
5912
|
-
json(res, scopeFunnelStatsPayload({ days, ...val, cachedAt: Date.now() }, req.user));
|
|
6132
|
+
funnelStatsCache.set(cacheKey, { at: Date.now(), value: val });
|
|
6133
|
+
json(res, scopeFunnelStatsPayload({ days, platform: platform || 'all', ...val, cachedAt: Date.now() }, req.user));
|
|
5913
6134
|
}).catch(err => {
|
|
5914
|
-
funnelStatsCache.delete(
|
|
6135
|
+
funnelStatsCache.delete(cacheKey);
|
|
5915
6136
|
json(res, { error: String(err && err.message || err) }, 500);
|
|
5916
6137
|
});
|
|
5917
6138
|
return;
|
|
@@ -7209,6 +7430,34 @@ const HTML = `<!DOCTYPE html>
|
|
|
7209
7430
|
.daily-metrics-tooltip .tt-row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text); }
|
|
7210
7431
|
.daily-metrics-tooltip .tt-row .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
|
7211
7432
|
.daily-metrics-tooltip .tt-row .val { margin-left: auto; }
|
|
7433
|
+
/* Trends tab — per-project breakdown. One row per project, each row has a
|
|
7434
|
+
left-aligned project label and the same column chart used in the main
|
|
7435
|
+
section (just shorter). Stack vertically, lazy-loaded on details open;
|
|
7436
|
+
re-rendered (no refetch) when the user toggles legend pills. */
|
|
7437
|
+
.per-project-breakdown { padding: 0 20px 14px; }
|
|
7438
|
+
.per-project-breakdown > summary { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; font-size: 12px; color: var(--text-secondary); user-select: none; list-style: none; }
|
|
7439
|
+
.per-project-breakdown > summary::-webkit-details-marker { display: none; }
|
|
7440
|
+
.per-project-breakdown > summary .pp-caret { display: inline-block; width: 10px; transition: transform 0.1s; }
|
|
7441
|
+
.per-project-breakdown[open] > summary .pp-caret { transform: rotate(90deg); }
|
|
7442
|
+
.per-project-breakdown > summary .pp-status { margin-left: auto; color: var(--text-muted); font-size: 11px; }
|
|
7443
|
+
.per-project-rows { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
|
7444
|
+
.per-project-row { display: grid; grid-template-columns: 140px 1fr; align-items: stretch; gap: 8px; padding: 4px 0; border-top: 1px dashed var(--border); }
|
|
7445
|
+
.per-project-row:first-child { border-top: none; }
|
|
7446
|
+
.per-project-label { font-size: 12px; color: var(--text); font-weight: 500; padding: 6px 0 0 4px; word-break: break-word; }
|
|
7447
|
+
.per-project-chart { position: relative; min-height: 130px; }
|
|
7448
|
+
.per-project-chart svg { display: block; width: 100%; height: 130px; overflow: visible; }
|
|
7449
|
+
.per-project-chart .gridline { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
|
|
7450
|
+
.per-project-chart .axis-text { fill: var(--text-secondary); font-size: 9px; font-variant-numeric: tabular-nums; }
|
|
7451
|
+
.per-project-chart .series-bar { transition: opacity 0.1s; }
|
|
7452
|
+
.per-project-chart .series-bar:hover { opacity: 0.85; }
|
|
7453
|
+
.per-project-chart .hover-line { stroke: var(--text-muted); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; pointer-events: none; }
|
|
7454
|
+
.per-project-chart .pp-tooltip { position: absolute; pointer-events: none; background: var(--bg-panel, #fff); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 11px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); min-width: 160px; max-width: 240px; opacity: 0; transition: opacity 0.08s; z-index: 5; left: 0; top: 0; }
|
|
7455
|
+
.per-project-chart .pp-tooltip .tt-day { font-weight: 600; margin-bottom: 3px; color: var(--text); }
|
|
7456
|
+
.per-project-chart .pp-tooltip .tt-row { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text); }
|
|
7457
|
+
.per-project-chart .pp-tooltip .tt-row .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
|
7458
|
+
.per-project-chart .pp-tooltip .tt-row .val { margin-left: auto; }
|
|
7459
|
+
.per-project-chart .pp-empty { padding: 14px 12px; color: var(--text-muted); font-size: 11px; text-align: center; }
|
|
7460
|
+
.per-project-row.pp-loading .per-project-chart .pp-empty { color: var(--text-secondary); }
|
|
7212
7461
|
/* Deploy Health: slim inline bar when collapsed, alert colors when there is something worth attention */
|
|
7213
7462
|
#deploy-health:not([open]) { margin-bottom: 10px; border-radius: 8px; }
|
|
7214
7463
|
#deploy-health:not([open]) > summary { padding: 6px 14px; }
|
|
@@ -7509,7 +7758,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
7509
7758
|
</div>
|
|
7510
7759
|
<details class="style-stats-section" id="cohort-stats" open>
|
|
7511
7760
|
<summary>
|
|
7512
|
-
<span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="cohort-stats-heading">Score Cohort Distribution (24h)</span><span class="stat-card-info" data-tooltip="Buckets posts by composite score (comments \u00D7 3 + upvotes; Reddit/Moltbook subtract 1 to strip OP self-upvote).
|
|
7761
|
+
<span class="style-stats-title"><span class="style-stats-caret">\u25B6</span><span id="cohort-stats-heading">Score Cohort Distribution (24h)</span><span class="stat-card-info" data-tooltip="Buckets posts by composite score (clicks \u00D7 10 + comments \u00D7 3 + upvotes; Reddit/Moltbook subtract 1 to strip OP self-upvote). Click weight \u00D710 because a real human click outvalues 10 likes of vibes (matches top_performers.SCORE_SQL and the engagement_styles.py picker). Views are deliberately excluded. Cohorts: Dead = 0, Low = 1\u20139, Mid = 10\u201329, High = 30+. Honors the Window / Platform / Project filters above.">i</span></span>
|
|
7513
7762
|
<span class="style-stats-total" id="cohort-stats-total"></span>
|
|
7514
7763
|
</summary>
|
|
7515
7764
|
<div id="cohort-stats-body">
|
|
@@ -7587,6 +7836,16 @@ const HTML = `<!DOCTYPE html>
|
|
|
7587
7836
|
<div id="daily-metrics-chart" class="daily-metrics-chart">
|
|
7588
7837
|
<div class="views-chart-empty">Loading…</div>
|
|
7589
7838
|
</div>
|
|
7839
|
+
<details class="per-project-breakdown" id="daily-metrics-per-project" data-scope="daily">
|
|
7840
|
+
<summary>
|
|
7841
|
+
<span class="pp-caret">▶</span>
|
|
7842
|
+
<span class="pp-label">Per-project breakdown</span>
|
|
7843
|
+
<span class="pp-status" id="daily-metrics-per-project-status"></span>
|
|
7844
|
+
</summary>
|
|
7845
|
+
<div class="per-project-rows" id="daily-metrics-per-project-rows">
|
|
7846
|
+
<div class="views-chart-empty">Click to load per-project charts.</div>
|
|
7847
|
+
</div>
|
|
7848
|
+
</details>
|
|
7590
7849
|
</div>
|
|
7591
7850
|
</details>
|
|
7592
7851
|
<details class="style-stats-section" id="ratio-metrics" open>
|
|
@@ -7599,6 +7858,16 @@ const HTML = `<!DOCTYPE html>
|
|
|
7599
7858
|
<div id="ratio-metrics-chart" class="daily-metrics-chart">
|
|
7600
7859
|
<div class="views-chart-empty">Loading…</div>
|
|
7601
7860
|
</div>
|
|
7861
|
+
<details class="per-project-breakdown" id="ratio-metrics-per-project" data-scope="ratio">
|
|
7862
|
+
<summary>
|
|
7863
|
+
<span class="pp-caret">▶</span>
|
|
7864
|
+
<span class="pp-label">Per-project breakdown</span>
|
|
7865
|
+
<span class="pp-status" id="ratio-metrics-per-project-status"></span>
|
|
7866
|
+
</summary>
|
|
7867
|
+
<div class="per-project-rows" id="ratio-metrics-per-project-rows">
|
|
7868
|
+
<div class="views-chart-empty">Click to load per-project charts.</div>
|
|
7869
|
+
</div>
|
|
7870
|
+
</details>
|
|
7602
7871
|
</div>
|
|
7603
7872
|
</details>
|
|
7604
7873
|
</div>
|
|
@@ -7916,11 +8185,31 @@ function toast(msg, isError) {
|
|
|
7916
8185
|
tipEl.style.left = left + 'px';
|
|
7917
8186
|
tipEl.style.top = top + 'px';
|
|
7918
8187
|
}
|
|
8188
|
+
// Render tooltip text with safe minimal markup:
|
|
8189
|
+
// 1. Escape all HTML special chars (so dynamic data can never inject HTML).
|
|
8190
|
+
// 2. Then turn double-asterisk sequences into bold. The escape step happens
|
|
8191
|
+
// FIRST, so even bolded angle-bracket text becomes inert.
|
|
8192
|
+
// 3. Newlines render via CSS white-space:pre-line (already on .sa-tooltip).
|
|
8193
|
+
// No conversion to br needed.
|
|
8194
|
+
// NOTE: this whole file sits inside the outer HTML backtick template, so all
|
|
8195
|
+
// escapes here must be DOUBLE backslashes — the outer template eats single
|
|
8196
|
+
// backslashes. The served JS sees single-backslash escapes, which the regex
|
|
8197
|
+
// engine then handles correctly.
|
|
8198
|
+
function renderTooltipMarkup(text) {
|
|
8199
|
+
var html = String(text)
|
|
8200
|
+
.replace(/&/g, '&')
|
|
8201
|
+
.replace(/</g, '<')
|
|
8202
|
+
.replace(/>/g, '>')
|
|
8203
|
+
.replace(/"/g, '"');
|
|
8204
|
+
// Non-greedy match so multiple **bold** runs on one line work.
|
|
8205
|
+
html = html.replace(/\\*\\*([^*\\n]+?)\\*\\*/g, '<b>$1</b>');
|
|
8206
|
+
return html;
|
|
8207
|
+
}
|
|
7919
8208
|
function show(host) {
|
|
7920
8209
|
const text = getText(host);
|
|
7921
8210
|
if (!text) return;
|
|
7922
8211
|
const el = ensureTip();
|
|
7923
|
-
el.
|
|
8212
|
+
el.innerHTML = renderTooltipMarkup(text);
|
|
7924
8213
|
el.classList.add('visible');
|
|
7925
8214
|
position(host);
|
|
7926
8215
|
currentHost = host;
|
|
@@ -8326,12 +8615,14 @@ function renderResult(run) {
|
|
|
8326
8615
|
const unmatchedN = scan.unmatched || 0;
|
|
8327
8616
|
const backfillN = scan.backfill || 0;
|
|
8328
8617
|
const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
|
|
8618
|
+
const SNL = String.fromCharCode(10);
|
|
8329
8619
|
const scanTooltip = hasScan
|
|
8330
|
-
? ('
|
|
8331
|
-
'
|
|
8332
|
-
'
|
|
8333
|
-
'
|
|
8334
|
-
'
|
|
8620
|
+
? ('**Inbox scan**' + SNL + SNL +
|
|
8621
|
+
'• **seen:** ' + scannedN + SNL +
|
|
8622
|
+
'• **new:** ' + newN + SNL +
|
|
8623
|
+
'• **excluded:** ' + excludedN + SNL +
|
|
8624
|
+
'• **unmatched:** ' + unmatchedN + SNL +
|
|
8625
|
+
'• **backfill_skipped:** ' + backfillN)
|
|
8335
8626
|
: '';
|
|
8336
8627
|
const scanPills = hasScan
|
|
8337
8628
|
? ('<span title="' + scanTooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
@@ -8365,7 +8656,8 @@ function renderResult(run) {
|
|
|
8365
8656
|
if (!failed && !reasons.length) return '';
|
|
8366
8657
|
const top = reasons[0];
|
|
8367
8658
|
const tt = reasons.length
|
|
8368
|
-
?
|
|
8659
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
8660
|
+
reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8369
8661
|
: 'failed (no reason logged)';
|
|
8370
8662
|
const label = top
|
|
8371
8663
|
? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
|
|
@@ -8375,12 +8667,16 @@ function renderResult(run) {
|
|
|
8375
8667
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
8376
8668
|
label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
|
|
8377
8669
|
};
|
|
8378
|
-
const
|
|
8379
|
-
|
|
8380
|
-
'
|
|
8381
|
-
|
|
8382
|
-
'
|
|
8383
|
-
'
|
|
8670
|
+
const LNL = String.fromCharCode(10);
|
|
8671
|
+
const tooltip =
|
|
8672
|
+
'**LinkedIn post-comment funnel**' + LNL +
|
|
8673
|
+
LNL +
|
|
8674
|
+
'• **searches:** ' + searches + LNL +
|
|
8675
|
+
'• **raw SERP candidates:** ' + raw + LNL +
|
|
8676
|
+
'• **passed 20.0 floor:** ' + passed + LNL +
|
|
8677
|
+
'• **dropped below floor:** ' + dropped + LNL +
|
|
8678
|
+
'• **posted:** ' + posted + LNL +
|
|
8679
|
+
'• **pending queue:** ' + queue;
|
|
8384
8680
|
return (
|
|
8385
8681
|
'<span title="' + tooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
8386
8682
|
pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
@@ -8431,7 +8727,8 @@ function renderResult(run) {
|
|
|
8431
8727
|
if (!failed && !reasons.length) return '';
|
|
8432
8728
|
const top = reasons[0];
|
|
8433
8729
|
const tt = reasons.length
|
|
8434
|
-
?
|
|
8730
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
8731
|
+
reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8435
8732
|
: 'failed (no reason logged)';
|
|
8436
8733
|
const label = top
|
|
8437
8734
|
? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
|
|
@@ -8466,27 +8763,27 @@ function renderResult(run) {
|
|
|
8466
8763
|
// CSS .sa-tooltip white-space:pre-line turns these into line breaks.
|
|
8467
8764
|
const NL = String.fromCharCode(10);
|
|
8468
8765
|
const tooltip =
|
|
8469
|
-
'Phase 0
|
|
8470
|
-
'• salvaged ' + salvAttempted + '
|
|
8471
|
-
'• pool for next cycle: ' + salvageableLive + ' salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
|
|
8766
|
+
'**Phase 0 — cleanup**' + NL +
|
|
8767
|
+
'• **salvaged ' + salvAttempted + '** — orphan rows adopted from prior dead cycles (' + salvPosted + ' posted this cycle)' + NL +
|
|
8768
|
+
'• pool for next cycle: **' + salvageableLive + '** salvageable (+' + salvAdded + ' / -' + salvDrained + ' this run)' + NL +
|
|
8472
8769
|
NL +
|
|
8473
|
-
'Phase 1
|
|
8474
|
-
'• searches ' + searches + '
|
|
8475
|
-
'• raw ' + raw + '
|
|
8476
|
-
'• passed ' + passed + '
|
|
8770
|
+
'**Phase 1 — scrape**' + NL +
|
|
8771
|
+
'• **searches ' + searches + '** — queries run' + NL +
|
|
8772
|
+
'• **raw ' + raw + '** — tweets returned' + NL +
|
|
8773
|
+
'• **passed ' + passed + '** — after dedup + age>18h cuts (' + dropped + ' dropped)' + NL +
|
|
8477
8774
|
NL +
|
|
8478
|
-
'Phase 2a
|
|
8479
|
-
'• expired ' + expired + '
|
|
8775
|
+
'**Phase 2a — Δ re-score**' + NL +
|
|
8776
|
+
'• **expired ' + expired + '** — below Δ<1 likes floor' + NL +
|
|
8480
8777
|
NL +
|
|
8481
|
-
'Phase 2b
|
|
8482
|
-
'•
|
|
8483
|
-
'• posted ' + posted + '
|
|
8484
|
-
'• failed ' + failed + '
|
|
8778
|
+
'**Phase 2b — draft + post**' + NL +
|
|
8779
|
+
'• **Δ≥10: ' + aboveFloor + '** — crossed POST_LIMIT=3 review cap' + NL +
|
|
8780
|
+
'• **posted ' + posted + '** — shipped' + NL +
|
|
8781
|
+
'• **failed ' + failed + '** — post errors' + NL +
|
|
8485
8782
|
NL +
|
|
8486
|
-
'Pending end-of-run
|
|
8487
|
-
'
|
|
8488
|
-
qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped
|
|
8489
|
-
'Pending live
|
|
8783
|
+
'**Pending end-of-run:** ' + queue + NL +
|
|
8784
|
+
' start ' + queueStart + ', +' + qAdded + ' / -' + qDrained + ' = ' +
|
|
8785
|
+
qDrainedPosted + ' posted, ' + qDrainedExpired + ' expired, ' + qDrainedSkipped + ' skipped' + NL +
|
|
8786
|
+
'**Pending live:** ' + pendingLive;
|
|
8490
8787
|
// Pill order mirrors the tooltip story: salvaged (Phase 0 input) leads,
|
|
8491
8788
|
// then Phase 1 funnel (searches, raw, passed), Phase 2a drop (expired),
|
|
8492
8789
|
// Phase 2b decision and outcome (Δ≥10, posted, failed).
|
|
@@ -8507,6 +8804,13 @@ function renderResult(run) {
|
|
|
8507
8804
|
+ r.projects_worked.join(', ')
|
|
8508
8805
|
+ '</span></span>'
|
|
8509
8806
|
: '') +
|
|
8807
|
+
(Array.isArray(r.styles_used) && r.styles_used.length
|
|
8808
|
+
? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
8809
|
+
+ 'styles '
|
|
8810
|
+
+ '<span style="color:var(--text);font-weight:600;">'
|
|
8811
|
+
+ r.styles_used.join(', ')
|
|
8812
|
+
+ '</span></span>'
|
|
8813
|
+
: '') +
|
|
8510
8814
|
'</span>'
|
|
8511
8815
|
);
|
|
8512
8816
|
}
|
|
@@ -8563,7 +8867,8 @@ function renderResult(run) {
|
|
|
8563
8867
|
? ' (' + reasons.map(function (x) { return x.reason + ' ' + x.count; }).join('; ') + ')'
|
|
8564
8868
|
: '';
|
|
8565
8869
|
const tt = reasons.length
|
|
8566
|
-
?
|
|
8870
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
8871
|
+
reasons.map(function (x) { return '• ' + x.reason + ': **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8567
8872
|
: 'failed (no reason logged)';
|
|
8568
8873
|
return '<span title="' + tt.replace(/"/g, '"') + '" ' +
|
|
8569
8874
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
@@ -8587,17 +8892,28 @@ function renderResult(run) {
|
|
|
8587
8892
|
// now means "threads that ACTUALLY came through the ripen step
|
|
8588
8893
|
// with positive momentum", which is what the word implies in plain
|
|
8589
8894
|
// English. Final chain: raw → passed → ripened → drafted → posted.
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8895
|
+
// Build the ripen tooltip with explicit newlines (NL is defined in the
|
|
8896
|
+
// outer scope; use String.fromCharCode(10) here in case the closure
|
|
8897
|
+
// walked away from it). See feedback_server_js_template_regex memory:
|
|
8898
|
+
// literal backslash-n inside the outer HTML backtick template gets eaten,
|
|
8899
|
+
// so we build the newline char numerically.
|
|
8900
|
+
const RNL = String.fromCharCode(10);
|
|
8901
|
+
const ripenTip =
|
|
8902
|
+
'**Ripen step** — 5-min Δ re-score' + RNL +
|
|
8903
|
+
RNL +
|
|
8904
|
+
'• **iterations:** ' + ripenIters + RNL +
|
|
8905
|
+
'• **input decisions** (= passed): ' + ripenInput + RNL +
|
|
8906
|
+
'• **ripened** (composite > ' + (ripenFloor != null ? ripenFloor : '?') + '): ' + ripenSurvivors + RNL +
|
|
8907
|
+
'• **drops:** ' + ripenDrops + RNL +
|
|
8908
|
+
'• **iters skipped** (0 survivors): ' + ripenSkipped + RNL +
|
|
8909
|
+
'• **iters passthrough** (no urls / rate limit): ' + ripenPassthrough +
|
|
8910
|
+
(ripenWindow != null ? RNL + RNL + '**window:** ' + ripenWindow + 's' : '') +
|
|
8911
|
+
(ripenW != null ? RNL + '**formula:** Δup + ' + ripenW + '×Δcomments' : '') +
|
|
8912
|
+
(bestComp != null
|
|
8913
|
+
? RNL + RNL + '**best:** composite=' + bestComp.toFixed(1) +
|
|
8914
|
+
' (Δup=' + (bestDup != null ? bestDup : '?') +
|
|
8915
|
+
', Δcomm=' + (bestDco != null ? bestDco : '?') + ')'
|
|
8916
|
+
: '');
|
|
8601
8917
|
const bestLabel = bestComp != null
|
|
8602
8918
|
? ('best Δ' + bestComp.toFixed(1))
|
|
8603
8919
|
: (ripenSurvivors > 0 ? 'best Δ?' : 'no Δ');
|
|
@@ -8654,35 +8970,55 @@ function renderResult(run) {
|
|
|
8654
8970
|
bracket = ' <span style="color:var(--muted);font-weight:400;">(' +
|
|
8655
8971
|
'+' + salvAdded + '/-' + salvDrained + ' pool)</span>';
|
|
8656
8972
|
}
|
|
8657
|
-
const
|
|
8658
|
-
|
|
8659
|
-
'
|
|
8660
|
-
|
|
8661
|
-
'
|
|
8662
|
-
'
|
|
8663
|
-
'
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
'
|
|
8973
|
+
const QNL = String.fromCharCode(10);
|
|
8974
|
+
const qTip =
|
|
8975
|
+
'**Salvage lane** — Phase 0 cleanup' + QNL +
|
|
8976
|
+
QNL +
|
|
8977
|
+
'• **attempts this run:** ' + salvAttempted + QNL +
|
|
8978
|
+
'• **posted:** ' + salvPosted + QNL +
|
|
8979
|
+
'• **failed:** ' + salvFailedNow + QNL +
|
|
8980
|
+
QNL +
|
|
8981
|
+
'**Salvageable pool** (next cycle): ' + salvageableLive + QNL +
|
|
8982
|
+
' +' + salvAdded + ' became salvageable, -' + salvDrained + ' drained out' + QNL +
|
|
8983
|
+
QNL +
|
|
8984
|
+
'**Pending pool end-of-run:** ' + queue + QNL +
|
|
8985
|
+
' start: ' + queueStartV + ', +' + qAdded + ' added, -' + qDrained + ' drained' + QNL +
|
|
8986
|
+
' drained = ' + qDrainedPosted + ' posted + ' + qDrainedFailed + ' failed + ' +
|
|
8987
|
+
qDrainedExpired + ' expired + ' + qDrainedSkipped + ' skipped' + QNL +
|
|
8988
|
+
QNL +
|
|
8989
|
+
'**Pending live now:** ' + pendingLive;
|
|
8667
8990
|
return '<span title="' + qTip.replace(/"/g, '"') + '" ' +
|
|
8668
8991
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
8669
8992
|
'salvaged <span style="color:var(--text);font-weight:600;">' + primaryN + '</span>' +
|
|
8670
8993
|
bracket + '</span>';
|
|
8671
8994
|
};
|
|
8672
8995
|
|
|
8673
|
-
const
|
|
8674
|
-
|
|
8675
|
-
'
|
|
8676
|
-
|
|
8677
|
-
'
|
|
8678
|
-
'
|
|
8679
|
-
'
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
'
|
|
8683
|
-
|
|
8996
|
+
const RDNL = String.fromCharCode(10);
|
|
8997
|
+
const tooltip =
|
|
8998
|
+
'**Reddit post-comment funnel**' + RDNL +
|
|
8999
|
+
RDNL +
|
|
9000
|
+
'• **iterations:** ' + iterations + RDNL +
|
|
9001
|
+
'• **searches:** ' + searches + RDNL +
|
|
9002
|
+
'• **raw API results:** ' + raw + RDNL +
|
|
9003
|
+
'• **passed** (post-API filter): ' + passed + RDNL +
|
|
9004
|
+
'• **dropped** (blocked sub / archived / locked / age>180d): ' + dropped + RDNL +
|
|
9005
|
+
'• **fetched** (model opened to read): ' + fetched + RDNL +
|
|
9006
|
+
'• **drafted:** ' + drafted +
|
|
9007
|
+
(ripenIters
|
|
9008
|
+
? RDNL + '• **ripen survivors:** ' + ripenSurvivors + ' / ' + ripenInput +
|
|
9009
|
+
(bestComp != null ? ' (best composite ' + bestComp.toFixed(1) + ')' : '')
|
|
9010
|
+
: '') + RDNL +
|
|
9011
|
+
'• **posted:** ' + posted +
|
|
9012
|
+
(salvageableLive ? RDNL + RDNL + '**Salvageable in DB:** ' + salvageableLive : '');
|
|
8684
9013
|
return (
|
|
8685
9014
|
'<span title="' + tooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
9015
|
+
// 2026-05-19: salvaged pill leads, mirroring Twitter chronological
|
|
9016
|
+
// pill order (server.js ~line 8723). Phase 0 salvage runs FIRST in
|
|
9017
|
+
// every reddit cycle (run-reddit-search.sh:241), so it belongs at the
|
|
9018
|
+
// start of the funnel, not buried after the posted pill. Twitter
|
|
9019
|
+
// renderer comment says: pill order mirrors the tooltip story,
|
|
9020
|
+
// salvaged (Phase 0 input) leads, then Phase 1 funnel, Phase 2b.
|
|
9021
|
+
renderQueuePill() +
|
|
8686
9022
|
pill('iterations', iterations, iterations > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
8687
9023
|
pill('searches', searches, searches > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
8688
9024
|
pill('raw', raw, raw > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
@@ -8696,7 +9032,6 @@ function renderResult(run) {
|
|
|
8696
9032
|
renderRipenPills() +
|
|
8697
9033
|
pill('drafted', drafted, drafted > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
8698
9034
|
pill('posted', posted, posted > 0 ? '#22c55e' : 'var(--muted)') +
|
|
8699
|
-
renderQueuePill() +
|
|
8700
9035
|
renderFailedPill() +
|
|
8701
9036
|
(Array.isArray(r.projects_worked) && r.projects_worked.length
|
|
8702
9037
|
? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
@@ -8705,6 +9040,13 @@ function renderResult(run) {
|
|
|
8705
9040
|
+ r.projects_worked.join(', ')
|
|
8706
9041
|
+ '</span></span>'
|
|
8707
9042
|
: '') +
|
|
9043
|
+
(Array.isArray(r.styles_used) && r.styles_used.length
|
|
9044
|
+
? '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">'
|
|
9045
|
+
+ 'styles '
|
|
9046
|
+
+ '<span style="color:var(--text);font-weight:600;">'
|
|
9047
|
+
+ r.styles_used.join(', ')
|
|
9048
|
+
+ '</span></span>'
|
|
9049
|
+
: '') +
|
|
8708
9050
|
'</span>'
|
|
8709
9051
|
);
|
|
8710
9052
|
}
|
|
@@ -8732,7 +9074,8 @@ function renderResult(run) {
|
|
|
8732
9074
|
if (!failed) return '';
|
|
8733
9075
|
const top = reasons[0];
|
|
8734
9076
|
const tooltip = reasons.length
|
|
8735
|
-
?
|
|
9077
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
9078
|
+
reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8736
9079
|
: 'failed (no reason logged)';
|
|
8737
9080
|
const label = top
|
|
8738
9081
|
? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
|
|
@@ -8752,12 +9095,14 @@ function renderResult(run) {
|
|
|
8752
9095
|
const unmatchedN = scan.unmatched || 0;
|
|
8753
9096
|
const backfillN = scan.backfill || 0;
|
|
8754
9097
|
const hasScan = scannedN || newN || excludedN || unmatchedN || backfillN;
|
|
9098
|
+
const SNL = String.fromCharCode(10);
|
|
8755
9099
|
const scanTooltip = hasScan
|
|
8756
|
-
? ('
|
|
8757
|
-
'
|
|
8758
|
-
'
|
|
8759
|
-
'
|
|
8760
|
-
'
|
|
9100
|
+
? ('**Inbox scan**' + SNL + SNL +
|
|
9101
|
+
'• **seen:** ' + scannedN + SNL +
|
|
9102
|
+
'• **new:** ' + newN + SNL +
|
|
9103
|
+
'• **excluded:** ' + excludedN + SNL +
|
|
9104
|
+
'• **unmatched:** ' + unmatchedN + SNL +
|
|
9105
|
+
'• **backfill_skipped:** ' + backfillN)
|
|
8761
9106
|
: '';
|
|
8762
9107
|
const scanPills = hasScan
|
|
8763
9108
|
? ('<span title="' + scanTooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
@@ -8831,48 +9176,54 @@ function renderResult(run) {
|
|
|
8831
9176
|
};
|
|
8832
9177
|
return (
|
|
8833
9178
|
tipPill('scanned', scanned, 'var(--text)',
|
|
8834
|
-
'
|
|
8835
|
-
'
|
|
9179
|
+
'**Scanned**\\n\\n' +
|
|
9180
|
+
'Total rows the run considered (every active row in the relevant platform tables).\\n\\n' +
|
|
9181
|
+
'= checked + skipped + bypassed-as-fresh.') +
|
|
8836
9182
|
(skipped ? tipPill('skipped', skipped, 'var(--muted)',
|
|
8837
|
-
'
|
|
8838
|
-
'
|
|
8839
|
-
'
|
|
8840
|
-
'
|
|
9183
|
+
'**Skipped**\\n\\n' +
|
|
9184
|
+
'Rows we deliberately did NOT poll this run.\\n\\n' +
|
|
9185
|
+
'**Two reasons:**\\n' +
|
|
9186
|
+
'• already refreshed by the cheap scrape leg within the last 4h\\n' +
|
|
9187
|
+
'• stable cooldown (2+ scans with no metric change AND older than 3 days)\\n\\n' +
|
|
9188
|
+
'Saves API calls; data is still current.') : '') +
|
|
8841
9189
|
tipPill('checked', checked, 'var(--text)',
|
|
8842
|
-
'
|
|
8843
|
-
'(Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed)
|
|
9190
|
+
'**Checked**\\n\\n' +
|
|
9191
|
+
'Rows we actually hit the platform API for this run (Reddit old.reddit.com JSON / fxtwitter / LinkedIn activity feed).\\n\\n' +
|
|
8844
9192
|
'Includes both successful polls and the ones that errored mid-fetch.') +
|
|
8845
9193
|
tipPill('changed', changed, '#22c55e',
|
|
8846
|
-
'
|
|
8847
|
-
'comments_count, views) actually moved since the prior scan
|
|
9194
|
+
'**Changed**\\n\\n' +
|
|
9195
|
+
'Subset of CHECKED where any tracked metric (upvotes, comments_count, views) actually moved since the prior scan.\\n\\n' +
|
|
8848
9196
|
'The real-activity signal; everything else is no-op polling.') +
|
|
8849
9197
|
(viewsRefreshed ? tipPill('views', viewsRefreshed, '#06b6d4',
|
|
8850
|
-
'
|
|
8851
|
-
'
|
|
8852
|
-
'comment + thread on /user/<name>/)
|
|
8853
|
-
'
|
|
8854
|
-
'tick up without upvotes/comments moving.') : '') +
|
|
9198
|
+
'**Views refreshed**\\n\\n' +
|
|
9199
|
+
'Rows where the cheap view-scrape leg wrote a fresh view count this run.\\n\\n' +
|
|
9200
|
+
'• **Reddit:** Step 1 profile-page scrape (sees every comment + thread on /user/<name>/)\\n' +
|
|
9201
|
+
'• **Twitter:** built-in to the fxtwitter call\\n\\n' +
|
|
9202
|
+
'Separate from CHANGED because views can tick up without upvotes/comments moving.') : '') +
|
|
8855
9203
|
(repliesRefreshed ? tipPill('replies', repliesRefreshed, '#3b82f6',
|
|
8856
|
-
'
|
|
8857
|
-
'someone else
|
|
8858
|
-
'
|
|
8859
|
-
'
|
|
8860
|
-
'Twitter also refreshes views via fxtwitter.') : '') +
|
|
9204
|
+
'**Replies refreshed**\\n\\n' +
|
|
9205
|
+
'Per-reply rows refreshed: comments we authored AS replies to someone else replying to our original comment (the DM-rail follow-up). Live in the replies table, not posts.\\n\\n' +
|
|
9206
|
+
'• **Reddit:** refreshes upvotes + reply-count via batch JSON API\\n' +
|
|
9207
|
+
'• **Twitter:** also refreshes views via fxtwitter') : '') +
|
|
8861
9208
|
(removed ? tipPill('removed', removed, '#eab308',
|
|
8862
|
-
'
|
|
8863
|
-
'
|
|
8864
|
-
'
|
|
9209
|
+
'**Removed**\\n\\n' +
|
|
9210
|
+
'Posts newly flagged deleted/removed this run.\\n\\n' +
|
|
9211
|
+
'• **Reddit:** comment gone from thread JSON for 2+ consecutive scans (deletion_detect_count threshold)\\n' +
|
|
9212
|
+
'• **LinkedIn:** post returned "Post unavailable"') : '') +
|
|
8865
9213
|
(unavailable ? tipPill('unavail', unavailable, '#eab308',
|
|
8866
|
-
'LinkedIn only
|
|
8867
|
-
'
|
|
8868
|
-
'operator can tell hard-deletion from rate-limit or network noise.') : '') +
|
|
9214
|
+
'**Unavailable** (LinkedIn only)\\n\\n' +
|
|
9215
|
+
'Post explicitly returned a Post Unavailable string.\\n\\n' +
|
|
9216
|
+
'Subset of REMOVED; rendered as its own pill so an operator can tell hard-deletion from rate-limit or network noise.') : '') +
|
|
8869
9217
|
(notFound ? tipPill('not found', notFound, 'var(--muted)',
|
|
8870
|
-
'
|
|
8871
|
-
'comment
|
|
8872
|
-
'aged off our visible recent-activity window).') : '') +
|
|
9218
|
+
'**Not found** (LinkedIn only)\\n\\n' +
|
|
9219
|
+
'Post is still active on LinkedIn but our specific comment could not be located on the activity feed (may have aged off our visible recent-activity window).') : '') +
|
|
8873
9220
|
(failed ? tipPill('failed', failed, '#ef4444',
|
|
8874
|
-
'
|
|
8875
|
-
'
|
|
9221
|
+
'**Failed**\\n\\n' +
|
|
9222
|
+
'API errors during the run, broken down by category:\\n' +
|
|
9223
|
+
'• 404 not_found\\n' +
|
|
9224
|
+
'• rate-limited (429)\\n' +
|
|
9225
|
+
'• empty / malformed response\\n' +
|
|
9226
|
+
'• other / network\\n\\n' +
|
|
8876
9227
|
'Includes step-exit failures from the shell pipeline as well.') : '')
|
|
8877
9228
|
);
|
|
8878
9229
|
}
|
|
@@ -8891,7 +9242,8 @@ function renderResult(run) {
|
|
|
8891
9242
|
if (!failed && !reasons.length) return '';
|
|
8892
9243
|
const top = reasons[0];
|
|
8893
9244
|
const tt = reasons.length
|
|
8894
|
-
?
|
|
9245
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
9246
|
+
reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8895
9247
|
: 'failed (no reason logged)';
|
|
8896
9248
|
const label = top
|
|
8897
9249
|
? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
|
|
@@ -8901,10 +9253,16 @@ function renderResult(run) {
|
|
|
8901
9253
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
8902
9254
|
label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
|
|
8903
9255
|
};
|
|
8904
|
-
const
|
|
8905
|
-
|
|
8906
|
-
'
|
|
8907
|
-
|
|
9256
|
+
const ENL = String.fromCharCode(10);
|
|
9257
|
+
const tooltip =
|
|
9258
|
+
'**SEO page expire**' + ENL +
|
|
9259
|
+
ENL +
|
|
9260
|
+
'• **total pages in scope:** ' + total + ENL +
|
|
9261
|
+
' (zero-click 30+ days, all projects)' + ENL +
|
|
9262
|
+
'• **deleted this run:** ' + deleted + ENL +
|
|
9263
|
+
'• **queue:** ' + queue + ENL +
|
|
9264
|
+
' (waiting for next run, capped by DAILY_MAX)' + ENL +
|
|
9265
|
+
'• **failed:** ' + failed;
|
|
8908
9266
|
return (
|
|
8909
9267
|
'<span title="' + tooltip.replace(/"/g, '"') + '" style="display:inline-block;">' +
|
|
8910
9268
|
pill('total pages', total, total > 0 ? 'var(--text)' : 'var(--muted)') +
|
|
@@ -8929,7 +9287,8 @@ function renderResult(run) {
|
|
|
8929
9287
|
if (!failed && !reasons.length) return '';
|
|
8930
9288
|
const top = reasons[0];
|
|
8931
9289
|
const tooltip = reasons.length
|
|
8932
|
-
?
|
|
9290
|
+
? '**Failure reasons**' + String.fromCharCode(10) +
|
|
9291
|
+
reasons.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; }).join(String.fromCharCode(10))
|
|
8933
9292
|
: 'failed (no reason logged)';
|
|
8934
9293
|
const label = top
|
|
8935
9294
|
? ('failed: ' + top.reason + (reasons.length > 1 ? ' +' + (reasons.length - 1) : ''))
|
|
@@ -8946,8 +9305,10 @@ function renderResult(run) {
|
|
|
8946
9305
|
const renderSkipReasonsPill = () => {
|
|
8947
9306
|
if (!skipBreakdown.length) return '';
|
|
8948
9307
|
const top = skipBreakdown[0];
|
|
8949
|
-
const tooltip =
|
|
8950
|
-
|
|
9308
|
+
const tooltip = '**Skip reasons**' + String.fromCharCode(10) +
|
|
9309
|
+
skipBreakdown
|
|
9310
|
+
.map(function (x) { return '• ' + x.reason + ' × **' + x.count + '**'; })
|
|
9311
|
+
.join(String.fromCharCode(10));
|
|
8951
9312
|
const label = 'skipped: ' + top.reason +
|
|
8952
9313
|
(skipBreakdown.length > 1 ? ' +' + (skipBreakdown.length - 1) : '');
|
|
8953
9314
|
const count = top.count;
|
|
@@ -8969,10 +9330,17 @@ function renderResult(run) {
|
|
|
8969
9330
|
const tp = discover.tweets_pulled || 0;
|
|
8970
9331
|
const c = discover.candidates || 0;
|
|
8971
9332
|
const af = discover.above_floor || 0;
|
|
8972
|
-
const
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
9333
|
+
const DNL = String.fromCharCode(10);
|
|
9334
|
+
const tip =
|
|
9335
|
+
'**Discovery funnel**' + DNL +
|
|
9336
|
+
DNL +
|
|
9337
|
+
'• **' + q + ' queries**' + (d ? ' (' + d + ' duds)' : '') + DNL +
|
|
9338
|
+
' \u2193' + DNL +
|
|
9339
|
+
'• **' + tp + ' tweets pulled**' + DNL +
|
|
9340
|
+
' \u2193' + DNL +
|
|
9341
|
+
'• **' + c + ' candidates** after floor' + DNL +
|
|
9342
|
+
' \u2193' + DNL +
|
|
9343
|
+
'• **' + af + ' cleared** review cap';
|
|
8976
9344
|
const visStr = q + '\u2192' + c + '\u2192' + af;
|
|
8977
9345
|
const color = (q || c || af) ? 'var(--text)' : 'var(--muted)';
|
|
8978
9346
|
return '<span title="' + tip.replace(/"/g, '"') + '" ' +
|
|
@@ -9115,16 +9483,16 @@ function _jobHistoryCostCell(result) {
|
|
|
9115
9483
|
headerHtml = fmtCost(totalForDisplay);
|
|
9116
9484
|
}
|
|
9117
9485
|
const lines = [
|
|
9118
|
-
'Cost (SDK orchestrator)
|
|
9486
|
+
'**Cost (SDK orchestrator):** ' + (sessionsWithSdk > 0 ? fmtLane(orch) : 'n/a'),
|
|
9119
9487
|
];
|
|
9120
9488
|
if (sessionsAll > 0) {
|
|
9121
|
-
lines.push('
|
|
9122
|
-
|
|
9123
|
-
|
|
9489
|
+
lines.push('• Sessions: **' + sessionsAll + '**');
|
|
9490
|
+
lines.push('• with SDK data: **' + sessionsWithSdk + '**');
|
|
9491
|
+
lines.push('• missing SDK: **' + sessionsMissing + '**');
|
|
9124
9492
|
}
|
|
9125
9493
|
if (bd && Array.isArray(bd.phases) && bd.phases.length) {
|
|
9126
9494
|
lines.push('');
|
|
9127
|
-
lines.push('Per-phase (claude_sessions.script grouping)
|
|
9495
|
+
lines.push('**Per-phase** (claude_sessions.script grouping)');
|
|
9128
9496
|
const shown = bd.phases.slice(0, 10);
|
|
9129
9497
|
for (const p of shown) {
|
|
9130
9498
|
const missing = (p.sessions_missing_sdk && p.sessions_missing_sdk > 0)
|
|
@@ -9133,7 +9501,7 @@ function _jobHistoryCostCell(result) {
|
|
|
9133
9501
|
const orchVal = (p.sessions_with_sdk && p.sessions_with_sdk > 0)
|
|
9134
9502
|
? fmtLane(p.orch)
|
|
9135
9503
|
: 'n/a';
|
|
9136
|
-
lines.push('
|
|
9504
|
+
lines.push('• ' + (p.phase || '(unknown)') + ' ×' + p.sessions +
|
|
9137
9505
|
' ' + orchVal + missing);
|
|
9138
9506
|
}
|
|
9139
9507
|
if (bd.phases.length > shown.length) {
|
|
@@ -9142,12 +9510,12 @@ function _jobHistoryCostCell(result) {
|
|
|
9142
9510
|
}
|
|
9143
9511
|
if (typeof result.cost_usd_from_log === 'number') {
|
|
9144
9512
|
lines.push('');
|
|
9145
|
-
lines.push('Wrapper shell-log value
|
|
9513
|
+
lines.push('**Wrapper shell-log value:** ' + fmtLane(result.cost_usd_from_log));
|
|
9146
9514
|
}
|
|
9147
9515
|
lines.push('');
|
|
9148
|
-
lines.push('Diagnostic-only (local pricing estimate, not actual billing)
|
|
9149
|
-
lines.push('
|
|
9150
|
-
lines.push('
|
|
9516
|
+
lines.push('**Diagnostic-only** (local pricing estimate, not actual billing)');
|
|
9517
|
+
lines.push('• Transcript estimate: ' + fmtLane(est));
|
|
9518
|
+
lines.push('• Subagent (est): ' + fmtLane(sub));
|
|
9151
9519
|
lines.push('');
|
|
9152
9520
|
lines.push('SDK-only mode: shows orchestrator_cost_usd captured by the SDK result event. "missing SDK" = wrapper script didn\\'t pass --output-format json to claude, so no result event = no cost data recorded. Patch the wrapper to fix.');
|
|
9153
9521
|
const tip = lines.join('\\n');
|
|
@@ -10085,11 +10453,11 @@ function fmtCostCell(displayed, orchestrator, estimated, subagent) {
|
|
|
10085
10453
|
? fmtCost(Number(orchestrator))
|
|
10086
10454
|
: '<span style="color:var(--text-very-faint);">n/a</span>';
|
|
10087
10455
|
const lines = [
|
|
10088
|
-
'Cost (SDK orchestrator)
|
|
10456
|
+
'**Cost (SDK orchestrator):** ' + fmtLane(orchestrator),
|
|
10089
10457
|
'',
|
|
10090
|
-
'Diagnostic-only (not actual billing)
|
|
10091
|
-
'
|
|
10092
|
-
'
|
|
10458
|
+
'**Diagnostic-only** (not actual billing)',
|
|
10459
|
+
'• Transcript estimate: ' + fmtLane(estimated),
|
|
10460
|
+
'• Subagent (est): ' + fmtLane(subagent),
|
|
10093
10461
|
'',
|
|
10094
10462
|
'Displays Anthropic-reported orchestrator_cost_usd only. "n/a" means the wrapper didn\\'t capture the SDK cost (no --output-format json on the claude call). Transcript estimate and subagent figures are computed locally from a hardcoded pricing table — informational, not billing-accurate on subscription plans.',
|
|
10095
10463
|
];
|
|
@@ -10404,8 +10772,12 @@ async function reloadStatsTabSections() {
|
|
|
10404
10772
|
// of the filter bar.
|
|
10405
10773
|
const funnelEl = document.getElementById('funnel-stats');
|
|
10406
10774
|
if (funnelEl && funnelEl.open) {
|
|
10407
|
-
|
|
10408
|
-
|
|
10775
|
+
// Always refetch when a filter pill changes — the cached payload was
|
|
10776
|
+
// built against the *previous* platform/window and rendering it would
|
|
10777
|
+
// contradict the engagement-style table above it. loadFunnelStats's
|
|
10778
|
+
// own (loadKey) check still keeps redundant fetches off when nothing
|
|
10779
|
+
// actually changed.
|
|
10780
|
+
pending.push(loadFunnelStats(true));
|
|
10409
10781
|
}
|
|
10410
10782
|
const dmEl = document.getElementById('dm-stats');
|
|
10411
10783
|
if (dmEl && dmEl.open) pending.push(loadDmStats(true));
|
|
@@ -10733,6 +11105,13 @@ function renderDailyMetrics() {
|
|
|
10733
11105
|
if (active.has(id)) active.delete(id); else active.add(id);
|
|
10734
11106
|
_saveDailyMetricsActive();
|
|
10735
11107
|
renderDailyMetrics();
|
|
11108
|
+
// Mirror the legend change across all per-project mini charts so they
|
|
11109
|
+
// stay in sync with the main chart's metric selection. No fetch.
|
|
11110
|
+
try {
|
|
11111
|
+
if (_perProjectState && _perProjectState.loadedKey) {
|
|
11112
|
+
_perProjectGetProjects().forEach(p => _renderPerProjectChart(p, 'daily'));
|
|
11113
|
+
}
|
|
11114
|
+
} catch {}
|
|
10736
11115
|
});
|
|
10737
11116
|
});
|
|
10738
11117
|
|
|
@@ -11056,6 +11435,13 @@ function renderRatioMetrics() {
|
|
|
11056
11435
|
if (active.has(id)) active.delete(id); else active.add(id);
|
|
11057
11436
|
_saveRatioMetricsActive();
|
|
11058
11437
|
renderRatioMetrics();
|
|
11438
|
+
// Same pattern as the daily-metrics legend: mirror the toggle across
|
|
11439
|
+
// the per-project ratio mini-charts (no fetch, just SVG rebuild).
|
|
11440
|
+
try {
|
|
11441
|
+
if (_perProjectState && _perProjectState.loadedKey) {
|
|
11442
|
+
_perProjectGetProjects().forEach(p => _renderPerProjectChart(p, 'ratio'));
|
|
11443
|
+
}
|
|
11444
|
+
} catch {}
|
|
11059
11445
|
});
|
|
11060
11446
|
});
|
|
11061
11447
|
const visible = RATIO_METRICS.filter(r => active.has(r.id));
|
|
@@ -11204,6 +11590,508 @@ function renderRatioMetrics() {
|
|
|
11204
11590
|
}
|
|
11205
11591
|
}
|
|
11206
11592
|
|
|
11593
|
+
// ============================================================================
|
|
11594
|
+
// Per-project breakdown for the Trends tab. Renders the same Daily Metrics
|
|
11595
|
+
// and Engagement Ratios charts (sharing the user's legend selection) once per
|
|
11596
|
+
// project, stacked vertically. Lazy-loaded on first details open; cached by
|
|
11597
|
+
// granularity+platform (so toggling either filter triggers a refetch but the
|
|
11598
|
+
// legend pills just re-render from the cached series). Always shows ALL
|
|
11599
|
+
// projects regardless of the Trends project filter — the breakdown is the
|
|
11600
|
+
// place to compare projects against each other.
|
|
11601
|
+
// ============================================================================
|
|
11602
|
+
const _perProjectState = {
|
|
11603
|
+
loadedKey: null, // 'daily|all' style cache key
|
|
11604
|
+
series: {}, // { project: { metricId: { dayISO: value } } }
|
|
11605
|
+
days: [], // axis (daily or weekly buckets)
|
|
11606
|
+
failed: {}, // { project: [{key, timedOut}] }
|
|
11607
|
+
loading: false, // a fetch cycle is in flight
|
|
11608
|
+
};
|
|
11609
|
+
|
|
11610
|
+
function _perProjectGetProjects() {
|
|
11611
|
+
const pills = document.querySelectorAll('#trends-project-pills .style-stats-pill[data-value]');
|
|
11612
|
+
return Array.from(pills)
|
|
11613
|
+
.map(p => p.getAttribute('data-value'))
|
|
11614
|
+
.filter(v => v && v !== 'all');
|
|
11615
|
+
}
|
|
11616
|
+
|
|
11617
|
+
function _perProjectCacheKey() {
|
|
11618
|
+
return currentTrendsGranularity() + '|' + currentTrendsPlatform();
|
|
11619
|
+
}
|
|
11620
|
+
|
|
11621
|
+
// True when at least one of the per-project details elements is open.
|
|
11622
|
+
// Used to decide whether to (re)load on filter change; if both are closed
|
|
11623
|
+
// the data is irrelevant until the user opens one.
|
|
11624
|
+
function _perProjectIsAnyOpen() {
|
|
11625
|
+
const a = document.getElementById('daily-metrics-per-project');
|
|
11626
|
+
const b = document.getElementById('ratio-metrics-per-project');
|
|
11627
|
+
return !!((a && a.open) || (b && b.open));
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
// Maybe-invalidate: only clears state when the cache key (granularity +
|
|
11631
|
+
// platform) actually changed. Project filter is irrelevant to per-project
|
|
11632
|
+
// breakdown (it always shows all projects), so a project-only change is a
|
|
11633
|
+
// no-op. Called from loadDailyMetrics on every refilter; cheap when no
|
|
11634
|
+
// section is open and no key change happened.
|
|
11635
|
+
function _perProjectInvalidate() {
|
|
11636
|
+
const want = _perProjectCacheKey();
|
|
11637
|
+
const changed = _perProjectState.loadedKey && _perProjectState.loadedKey !== want;
|
|
11638
|
+
if (changed) {
|
|
11639
|
+
_perProjectState.loadedKey = null;
|
|
11640
|
+
_perProjectState.series = {};
|
|
11641
|
+
_perProjectState.failed = {};
|
|
11642
|
+
}
|
|
11643
|
+
if (_perProjectIsAnyOpen()) {
|
|
11644
|
+
_perProjectLoadIfNeeded();
|
|
11645
|
+
} else if (changed) {
|
|
11646
|
+
// Reset both row containers to a hint state so when the user opens the
|
|
11647
|
+
// section next, they don't see stale data.
|
|
11648
|
+
['daily-metrics-per-project-rows', 'ratio-metrics-per-project-rows'].forEach(id => {
|
|
11649
|
+
const el = document.getElementById(id);
|
|
11650
|
+
if (el) el.innerHTML = '<div class="views-chart-empty">Click to load per-project charts.</div>';
|
|
11651
|
+
});
|
|
11652
|
+
['daily-metrics-per-project-status', 'ratio-metrics-per-project-status'].forEach(id => {
|
|
11653
|
+
const el = document.getElementById(id);
|
|
11654
|
+
if (el) el.textContent = '';
|
|
11655
|
+
});
|
|
11656
|
+
}
|
|
11657
|
+
}
|
|
11658
|
+
|
|
11659
|
+
// Single fetch helper: 7 best-effort endpoints (8 in admin mode w/ cost) for
|
|
11660
|
+
// one project, mirroring the shape of loadDailyMetrics. Returns a flat
|
|
11661
|
+
// { series: { metricId: { day: value } }, failed: [...] } record.
|
|
11662
|
+
async function _perProjectFetchOne(project, gran, platform, fetchDays, dailyAxis) {
|
|
11663
|
+
// 30s per endpoint. /api/funnel/per-day shells out to PostHog HogQL which
|
|
11664
|
+
// routinely takes 10-25s on a cold cache; 9s was killing 19 of 23 projects'
|
|
11665
|
+
// funnel fetch on first page-load and rendering them as silent zeros.
|
|
11666
|
+
// Pair this with the funnel pre-warmer (launchd com.m13v.social-funnel-prewarm)
|
|
11667
|
+
// which keeps the 5-min server cache hot so most fetches return instantly.
|
|
11668
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
11669
|
+
const fetchOne = async (url) => {
|
|
11670
|
+
const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
11671
|
+
const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch {} }, FETCH_TIMEOUT_MS) : null;
|
|
11672
|
+
try {
|
|
11673
|
+
const res = await fetch(url, ctl ? { signal: ctl.signal } : undefined);
|
|
11674
|
+
if (!res.ok) return { rows: [], failed: true, status: res.status };
|
|
11675
|
+
const data = await res.json();
|
|
11676
|
+
return { rows: data.rows || [], failed: false };
|
|
11677
|
+
} catch (e) {
|
|
11678
|
+
const msg = String(e && e.message || e);
|
|
11679
|
+
const timedOut = msg.includes('aborted') || msg.includes('Timeout') || msg.includes('timed out');
|
|
11680
|
+
return { rows: [], failed: true, timedOut };
|
|
11681
|
+
} finally {
|
|
11682
|
+
if (timer) clearTimeout(timer);
|
|
11683
|
+
}
|
|
11684
|
+
};
|
|
11685
|
+
// Force the project filter; platform stays inherited from the Trends bar.
|
|
11686
|
+
const platformAwareParams = ['days=' + fetchDays, 'project=' + encodeURIComponent(project)];
|
|
11687
|
+
if (platform && platform !== 'all') platformAwareParams.push('platform=' + encodeURIComponent(platform));
|
|
11688
|
+
const projectOnlyParams = ['days=' + fetchDays, 'project=' + encodeURIComponent(project)];
|
|
11689
|
+
const qsAware = platformAwareParams.join('&');
|
|
11690
|
+
const qsProj = projectOnlyParams.join('&');
|
|
11691
|
+
const costAvail = window.SA_IS_ADMIN !== false;
|
|
11692
|
+
const [views, upvotes, comments, clicks, bookings, funnel, cost, posts] = await Promise.all([
|
|
11693
|
+
fetchOne('/api/views/per-day?' + qsAware),
|
|
11694
|
+
fetchOne('/api/upvotes/per-day?' + qsAware),
|
|
11695
|
+
fetchOne('/api/comments/per-day?' + qsAware),
|
|
11696
|
+
fetchOne('/api/clicks/per-day?' + qsAware),
|
|
11697
|
+
fetchOne('/api/bookings/per-day?' + qsProj),
|
|
11698
|
+
fetchOne('/api/funnel/per-day?' + qsProj),
|
|
11699
|
+
costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
|
|
11700
|
+
fetchOne('/api/posts/per-day?' + qsAware),
|
|
11701
|
+
]);
|
|
11702
|
+
const series = {};
|
|
11703
|
+
const intoSeries = (id, rows, key) => {
|
|
11704
|
+
const map = {};
|
|
11705
|
+
rows.forEach(r => { if (r && r.day) map[r.day] = Number(r[key]) || 0; });
|
|
11706
|
+
series[id] = map;
|
|
11707
|
+
};
|
|
11708
|
+
intoSeries('views', views.rows, 'views_gained');
|
|
11709
|
+
intoSeries('upvotes', upvotes.rows, 'upvotes_gained');
|
|
11710
|
+
intoSeries('comments', comments.rows, 'comments_gained');
|
|
11711
|
+
intoSeries('clicks', clicks.rows, 'clicks_gained');
|
|
11712
|
+
intoSeries('bookings', bookings.rows, 'bookings_gained');
|
|
11713
|
+
intoSeries('cost', cost.rows, 'cost_usd');
|
|
11714
|
+
intoSeries('posts', posts.rows, 'posts_made');
|
|
11715
|
+
intoSeries('threads', posts.rows, 'threads_made');
|
|
11716
|
+
intoSeries('comments_made', posts.rows, 'comments_made');
|
|
11717
|
+
DAILY_METRICS.filter(m => m.funnel).forEach(m => {
|
|
11718
|
+
intoSeries(m.id, funnel.rows, m.valueKey);
|
|
11719
|
+
});
|
|
11720
|
+
// Apply weekly bucketing if needed (same shape as loadDailyMetrics).
|
|
11721
|
+
let outSeries = series;
|
|
11722
|
+
if (gran === 'weekly') {
|
|
11723
|
+
const aggregated = {};
|
|
11724
|
+
DAILY_METRICS.forEach(m => {
|
|
11725
|
+
const bucketed = _bucketWeekly(dailyAxis, series[m.id] || {});
|
|
11726
|
+
aggregated[m.id] = bucketed.weeklyMap;
|
|
11727
|
+
});
|
|
11728
|
+
outSeries = aggregated;
|
|
11729
|
+
}
|
|
11730
|
+
const fetchResults = { views, upvotes, comments, clicks, bookings, funnel, cost, posts };
|
|
11731
|
+
const failed = Object.keys(fetchResults)
|
|
11732
|
+
.filter(k => fetchResults[k].failed)
|
|
11733
|
+
.map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
|
|
11734
|
+
return { series: outSeries, failed };
|
|
11735
|
+
}
|
|
11736
|
+
|
|
11737
|
+
// Run an async fn over items with bounded concurrency. Used so we don't
|
|
11738
|
+
// fire 8 endpoints * 25 projects = 200 simultaneous requests at PostHog.
|
|
11739
|
+
async function _perProjectRunConcurrency(items, limit, fn) {
|
|
11740
|
+
const queue = items.slice();
|
|
11741
|
+
const workers = [];
|
|
11742
|
+
for (let i = 0; i < Math.min(limit, queue.length); i++) {
|
|
11743
|
+
workers.push((async () => {
|
|
11744
|
+
while (queue.length) {
|
|
11745
|
+
const item = queue.shift();
|
|
11746
|
+
if (item == null) break;
|
|
11747
|
+
try { await fn(item); } catch {}
|
|
11748
|
+
}
|
|
11749
|
+
})());
|
|
11750
|
+
}
|
|
11751
|
+
await Promise.all(workers);
|
|
11752
|
+
}
|
|
11753
|
+
|
|
11754
|
+
// Build a compact column chart SVG. Same visual model as renderDailyMetrics
|
|
11755
|
+
// but smaller (130px tall, no value labels above bars, X labels at first/
|
|
11756
|
+
// middle/last only). Returns { svgHtml, plot } where plot exposes the
|
|
11757
|
+
// padding + dimensions for hover wiring.
|
|
11758
|
+
function _buildMiniChartSvg(opts) {
|
|
11759
|
+
const days = opts.days || [];
|
|
11760
|
+
const visible = opts.visible || [];
|
|
11761
|
+
const valueOf = opts.valueOf; // (metric, day) => number|null
|
|
11762
|
+
const isRatio = !!opts.isRatio;
|
|
11763
|
+
const formatter = opts.formatter; // (metric, value) => string
|
|
11764
|
+
const seriesPeak = {};
|
|
11765
|
+
visible.forEach(m => {
|
|
11766
|
+
let p = 0;
|
|
11767
|
+
days.forEach(d => {
|
|
11768
|
+
const v = valueOf(m, d);
|
|
11769
|
+
if (v == null || !isFinite(v)) return;
|
|
11770
|
+
p = Math.max(p, v);
|
|
11771
|
+
});
|
|
11772
|
+
seriesPeak[m.id] = p;
|
|
11773
|
+
});
|
|
11774
|
+
const isSingle = visible.length === 1;
|
|
11775
|
+
const singleMetric = isSingle ? visible[0] : null;
|
|
11776
|
+
const yMax = isSingle ? _niceMax(Math.max(isRatio ? 0.01 : 0, seriesPeak[visible[0].id] || 0)) : 0;
|
|
11777
|
+
const width = 960;
|
|
11778
|
+
const height = 130;
|
|
11779
|
+
const padL = isSingle ? 44 : 12;
|
|
11780
|
+
const padR = 12, padT = 10, padB = 16;
|
|
11781
|
+
const plotW = width - padL - padR;
|
|
11782
|
+
const plotH = height - padT - padB;
|
|
11783
|
+
const groupWidth = days.length > 0 ? plotW / days.length : 0;
|
|
11784
|
+
const groupGap = Math.max(1, groupWidth * 0.18);
|
|
11785
|
+
const barCount = visible.length;
|
|
11786
|
+
const barSpacing = barCount > 1 ? 1 : 0;
|
|
11787
|
+
const innerWidth = Math.max(1, groupWidth - groupGap);
|
|
11788
|
+
const barWidth = Math.max(1, (innerWidth - barSpacing * (barCount - 1)) / barCount);
|
|
11789
|
+
const yOfFor = (m) => {
|
|
11790
|
+
const peak = isSingle ? yMax : (seriesPeak[m.id] || 0);
|
|
11791
|
+
return v => padT + plotH - (peak > 0 ? (v / peak) * plotH : 0);
|
|
11792
|
+
};
|
|
11793
|
+
const xCenter = i => padL + groupWidth * i + groupWidth / 2;
|
|
11794
|
+
const yGrid = [0, 0.5, 1].map(t => {
|
|
11795
|
+
const y = padT + plotH - t * plotH;
|
|
11796
|
+
let label = '';
|
|
11797
|
+
if (isSingle) {
|
|
11798
|
+
const v = yMax * t;
|
|
11799
|
+
label = '<text class="axis-text" x="' + (padL - 6) + '" y="' + (y + 3) + '" text-anchor="end">' + escapeHtml(formatter(singleMetric, v)) + '</text>';
|
|
11800
|
+
}
|
|
11801
|
+
return '<line class="gridline" x1="' + padL + '" x2="' + (width - padR) + '" y1="' + y + '" y2="' + y + '"/>' + label;
|
|
11802
|
+
}).join('');
|
|
11803
|
+
const xLabelIdxs = days.length <= 1
|
|
11804
|
+
? [0]
|
|
11805
|
+
: [0, Math.floor(days.length / 2), days.length - 1];
|
|
11806
|
+
const xLabels = Array.from(new Set(xLabelIdxs)).map(i => {
|
|
11807
|
+
return '<text class="axis-text" x="' + xCenter(i) + '" y="' + (height - 4) + '" text-anchor="middle">' + escapeHtml(_fmtDay(days[i])) + '</text>';
|
|
11808
|
+
}).join('');
|
|
11809
|
+
const bars = days.map((d, i) => {
|
|
11810
|
+
const groupX = padL + groupWidth * i + groupGap / 2;
|
|
11811
|
+
return visible.map((m, mi) => {
|
|
11812
|
+
const v = valueOf(m, d);
|
|
11813
|
+
if (v == null || !isFinite(v)) return '';
|
|
11814
|
+
if (!isRatio && v === 0) return ''; // skip zero bars for cleanliness
|
|
11815
|
+
const x = groupX + (barWidth + barSpacing) * mi;
|
|
11816
|
+
const yOf = yOfFor(m);
|
|
11817
|
+
const y = yOf(v);
|
|
11818
|
+
const h = Math.max(0, padT + plotH - y);
|
|
11819
|
+
return '<rect class="series-bar" data-day="' + escapeHtml(d) + '" data-metric="' + escapeHtml(m.id) + '" x="' + x.toFixed(2) + '" y="' + y.toFixed(2) + '" width="' + barWidth.toFixed(2) + '" height="' + h.toFixed(2) + '" fill="' + m.color + '"/>';
|
|
11820
|
+
}).join('');
|
|
11821
|
+
}).join('');
|
|
11822
|
+
const svgHtml =
|
|
11823
|
+
'<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none" role="img">' +
|
|
11824
|
+
yGrid + xLabels + bars +
|
|
11825
|
+
'<line class="hover-line" x1="0" y1="' + padT + '" x2="0" y2="' + (padT + plotH) + '"/>' +
|
|
11826
|
+
'<rect class="pp-hover-rect" x="' + padL + '" y="' + padT + '" width="' + plotW + '" height="' + plotH + '" fill="transparent"/>' +
|
|
11827
|
+
'</svg>' +
|
|
11828
|
+
'<div class="pp-tooltip"></div>';
|
|
11829
|
+
return { svgHtml, plot: { width, height, padL, padR, padT, padB, plotW, plotH, groupWidth, xCenter } };
|
|
11830
|
+
}
|
|
11831
|
+
|
|
11832
|
+
// Attach hover behavior to a mini chart inside chartEl. visible / valueOf /
|
|
11833
|
+
// formatter match the build call so the tooltip shows the same values.
|
|
11834
|
+
function _wireMiniChartHover(chartEl, plot, opts) {
|
|
11835
|
+
const days = opts.days;
|
|
11836
|
+
const visible = opts.visible;
|
|
11837
|
+
const valueOf = opts.valueOf;
|
|
11838
|
+
const formatter = opts.formatter;
|
|
11839
|
+
const emptyLabelFor = opts.emptyLabelFor; // (metric) => string | null
|
|
11840
|
+
const rect = chartEl.querySelector('.pp-hover-rect');
|
|
11841
|
+
const hoverLine = chartEl.querySelector('.hover-line');
|
|
11842
|
+
const tip = chartEl.querySelector('.pp-tooltip');
|
|
11843
|
+
if (!rect || !hoverLine || !tip) return;
|
|
11844
|
+
const show = e => {
|
|
11845
|
+
const svgEl = chartEl.querySelector('svg');
|
|
11846
|
+
const box = svgEl.getBoundingClientRect();
|
|
11847
|
+
const relX = e.clientX - box.left;
|
|
11848
|
+
const scale = plot.width / box.width;
|
|
11849
|
+
const svgX = relX * scale;
|
|
11850
|
+
const idxRaw = (svgX - plot.padL) / (plot.groupWidth || 1);
|
|
11851
|
+
const idx = Math.max(0, Math.min(days.length - 1, Math.floor(idxRaw)));
|
|
11852
|
+
const snapX = plot.xCenter(idx);
|
|
11853
|
+
hoverLine.setAttribute('x1', snapX);
|
|
11854
|
+
hoverLine.setAttribute('x2', snapX);
|
|
11855
|
+
hoverLine.style.opacity = '1';
|
|
11856
|
+
const day = days[idx];
|
|
11857
|
+
const rows = visible.map(m => {
|
|
11858
|
+
const v = valueOf(m, day);
|
|
11859
|
+
let display;
|
|
11860
|
+
if (v == null || !isFinite(v)) {
|
|
11861
|
+
display = (emptyLabelFor ? emptyLabelFor(m) : '\u2014');
|
|
11862
|
+
} else {
|
|
11863
|
+
display = formatter(m, v);
|
|
11864
|
+
}
|
|
11865
|
+
return '<div class="tt-row"><span class="swatch" style="background:' + m.color + ';"></span>' +
|
|
11866
|
+
'<span>' + escapeHtml(m.label) + '</span>' +
|
|
11867
|
+
'<span class="val">' + escapeHtml(display) + '</span></div>';
|
|
11868
|
+
}).join('');
|
|
11869
|
+
tip.innerHTML = '<div class="tt-day">' + escapeHtml(_fmtDay(day)) + '</div>' + rows;
|
|
11870
|
+
const cssX = snapX / scale;
|
|
11871
|
+
tip.style.transform = 'none';
|
|
11872
|
+
tip.style.visibility = 'hidden';
|
|
11873
|
+
tip.style.opacity = '1';
|
|
11874
|
+
const tipW = tip.offsetWidth;
|
|
11875
|
+
const tipH = tip.offsetHeight;
|
|
11876
|
+
const cw = chartEl.clientWidth;
|
|
11877
|
+
const margin = 4;
|
|
11878
|
+
const gap = 10;
|
|
11879
|
+
const chartBox = chartEl.getBoundingClientRect();
|
|
11880
|
+
const svgOffsetLeft = box.left - chartBox.left;
|
|
11881
|
+
const cursorPx = svgOffsetLeft + cssX;
|
|
11882
|
+
let leftPx = cursorPx - tipW / 2;
|
|
11883
|
+
if (leftPx + tipW > cw - margin) {
|
|
11884
|
+
leftPx = cursorPx - tipW - gap;
|
|
11885
|
+
if (leftPx < margin) leftPx = Math.max(margin, cw - margin - tipW);
|
|
11886
|
+
} else if (leftPx < margin) {
|
|
11887
|
+
leftPx = cursorPx + gap;
|
|
11888
|
+
if (leftPx + tipW > cw - margin) leftPx = Math.max(margin, cw - margin - tipW);
|
|
11889
|
+
}
|
|
11890
|
+
let topPx = plot.padT - 6 - tipH;
|
|
11891
|
+
if (topPx < margin) topPx = plot.padT + 12;
|
|
11892
|
+
tip.style.left = leftPx + 'px';
|
|
11893
|
+
tip.style.top = topPx + 'px';
|
|
11894
|
+
tip.style.visibility = '';
|
|
11895
|
+
};
|
|
11896
|
+
const hide = () => { hoverLine.style.opacity = '0'; tip.style.opacity = '0'; };
|
|
11897
|
+
rect.addEventListener('mousemove', show);
|
|
11898
|
+
rect.addEventListener('mouseleave', hide);
|
|
11899
|
+
}
|
|
11900
|
+
|
|
11901
|
+
// Render one mini chart for a single project in either 'daily' or 'ratio' scope.
|
|
11902
|
+
function _renderPerProjectChart(project, scope) {
|
|
11903
|
+
const rowsEl = document.getElementById(scope === 'daily' ? 'daily-metrics-per-project-rows' : 'ratio-metrics-per-project-rows');
|
|
11904
|
+
if (!rowsEl) return;
|
|
11905
|
+
const rowEl = rowsEl.querySelector('.per-project-row[data-project="' + (window.CSS && CSS.escape ? CSS.escape(project) : project) + '"]');
|
|
11906
|
+
if (!rowEl) return;
|
|
11907
|
+
const chartEl = rowEl.querySelector('.per-project-chart');
|
|
11908
|
+
if (!chartEl) return;
|
|
11909
|
+
const projectSeries = _perProjectState.series[project];
|
|
11910
|
+
if (!projectSeries) {
|
|
11911
|
+
chartEl.innerHTML = '<div class="pp-empty">Loading\u2026</div>';
|
|
11912
|
+
rowEl.classList.add('pp-loading');
|
|
11913
|
+
return;
|
|
11914
|
+
}
|
|
11915
|
+
rowEl.classList.remove('pp-loading');
|
|
11916
|
+
const days = _perProjectState.days;
|
|
11917
|
+
// Map each metric id -> the endpoint key its data comes from in _perProjectFetchOne.
|
|
11918
|
+
// Used below to detect when ALL visible metrics are powered by a failed endpoint
|
|
11919
|
+
// (e.g. funnel timed out for this project), so we can render an explicit "failed
|
|
11920
|
+
// to load" hint instead of silently drawing flat zeros that look like real data.
|
|
11921
|
+
const failedKeys = new Set((_perProjectState.failed[project] || []).map(f => f.key));
|
|
11922
|
+
const _sourceKeyForMetric = (m) => {
|
|
11923
|
+
if (m.funnel) return 'funnel';
|
|
11924
|
+
if (m.id === 'posts' || m.id === 'threads' || m.id === 'comments_made') return 'posts';
|
|
11925
|
+
return m.id; // views / upvotes / comments / clicks / bookings / cost match by id
|
|
11926
|
+
};
|
|
11927
|
+
if (scope === 'daily') {
|
|
11928
|
+
const active = _loadDailyMetricsActive();
|
|
11929
|
+
const visible = DAILY_METRICS.filter(m => active.has(m.id));
|
|
11930
|
+
if (!visible.length) {
|
|
11931
|
+
chartEl.innerHTML = '<div class="pp-empty">No metrics selected.</div>';
|
|
11932
|
+
return;
|
|
11933
|
+
}
|
|
11934
|
+
// If every visible metric's source endpoint failed for this project,
|
|
11935
|
+
// show a clear failure hint instead of silent zeros.
|
|
11936
|
+
if (visible.every(m => failedKeys.has(_sourceKeyForMetric(m)))) {
|
|
11937
|
+
const failKeys = Array.from(new Set(visible.map(m => _sourceKeyForMetric(m))));
|
|
11938
|
+
const timedOut = (_perProjectState.failed[project] || []).some(f => f.timedOut && failKeys.includes(f.key));
|
|
11939
|
+
chartEl.innerHTML = '<div class="pp-empty" style="color:#dc2626;">Failed to load (' + (timedOut ? 'timed out' : 'error') + ': ' + escapeHtml(failKeys.join(', ')) + ')</div>';
|
|
11940
|
+
return;
|
|
11941
|
+
}
|
|
11942
|
+
const valueOf = (m, d) => Number((projectSeries[m.id] || {})[d]) || 0;
|
|
11943
|
+
const formatter = (m, v) => _fmtForMetric(m, v);
|
|
11944
|
+
const { svgHtml, plot } = _buildMiniChartSvg({ days, visible, valueOf, isRatio: false, formatter });
|
|
11945
|
+
chartEl.innerHTML = svgHtml;
|
|
11946
|
+
_wireMiniChartHover(chartEl, plot, { days, visible, valueOf, formatter });
|
|
11947
|
+
} else {
|
|
11948
|
+
const active = _loadRatioMetricsActive();
|
|
11949
|
+
const visible = RATIO_METRICS.filter(r => active.has(r.id));
|
|
11950
|
+
if (!visible.length) {
|
|
11951
|
+
chartEl.innerHTML = '<div class="pp-empty">No ratios selected.</div>';
|
|
11952
|
+
return;
|
|
11953
|
+
}
|
|
11954
|
+
// Build per-ratio per-day series from this project's numerator/denominator.
|
|
11955
|
+
const ratioCache = {};
|
|
11956
|
+
visible.forEach(r => {
|
|
11957
|
+
const numByDay = projectSeries[r.numerator] || {};
|
|
11958
|
+
const denByDay = projectSeries[r.denominator] || {};
|
|
11959
|
+
const scale = (typeof r.scaleFactor === 'number') ? r.scaleFactor : 100;
|
|
11960
|
+
const map = {};
|
|
11961
|
+
days.forEach(d => {
|
|
11962
|
+
const num = Number(numByDay[d]) || 0;
|
|
11963
|
+
const den = Number(denByDay[d]) || 0;
|
|
11964
|
+
map[d] = (den > 0) ? (num / den * scale) : null;
|
|
11965
|
+
});
|
|
11966
|
+
ratioCache[r.id] = map;
|
|
11967
|
+
});
|
|
11968
|
+
const valueOf = (r, d) => ratioCache[r.id][d];
|
|
11969
|
+
const formatter = (r, v) => _fmtForRatio(r, v);
|
|
11970
|
+
const emptyLabelFor = (r) => (r.denominator === 'pageviews') ? 'no visitors'
|
|
11971
|
+
: (r.denominator === 'posts') ? 'no posts'
|
|
11972
|
+
: 'no views';
|
|
11973
|
+
const { svgHtml, plot } = _buildMiniChartSvg({ days, visible, valueOf, isRatio: true, formatter });
|
|
11974
|
+
chartEl.innerHTML = svgHtml;
|
|
11975
|
+
_wireMiniChartHover(chartEl, plot, { days, visible, valueOf, formatter, emptyLabelFor });
|
|
11976
|
+
}
|
|
11977
|
+
}
|
|
11978
|
+
|
|
11979
|
+
// Render BOTH scopes (daily + ratio) for one project. Cheap; only the open
|
|
11980
|
+
// section will be visually scrolled into view, but rendering both keeps the
|
|
11981
|
+
// state symmetric.
|
|
11982
|
+
function _renderPerProjectBoth(project) {
|
|
11983
|
+
_renderPerProjectChart(project, 'daily');
|
|
11984
|
+
_renderPerProjectChart(project, 'ratio');
|
|
11985
|
+
}
|
|
11986
|
+
|
|
11987
|
+
// Re-render every per-project row for both scopes. Used after legend pill
|
|
11988
|
+
// toggles (no fetch, just SVG rebuild) and after a fresh load completes.
|
|
11989
|
+
function _renderPerProjectAll() {
|
|
11990
|
+
const projects = _perProjectGetProjects();
|
|
11991
|
+
projects.forEach(p => _renderPerProjectBoth(p));
|
|
11992
|
+
}
|
|
11993
|
+
|
|
11994
|
+
// Build (or rebuild) the row skeleton in both containers. One row per project
|
|
11995
|
+
// with a project label and an empty .per-project-chart waiting for render.
|
|
11996
|
+
function _renderPerProjectSkeleton() {
|
|
11997
|
+
const projects = _perProjectGetProjects();
|
|
11998
|
+
['daily-metrics-per-project-rows', 'ratio-metrics-per-project-rows'].forEach(id => {
|
|
11999
|
+
const el = document.getElementById(id);
|
|
12000
|
+
if (!el) return;
|
|
12001
|
+
if (!projects.length) {
|
|
12002
|
+
el.innerHTML = '<div class="views-chart-empty">No projects.</div>';
|
|
12003
|
+
return;
|
|
12004
|
+
}
|
|
12005
|
+
el.innerHTML = projects.map(p => (
|
|
12006
|
+
'<div class="per-project-row pp-loading" data-project="' + escapeHtml(p) + '">' +
|
|
12007
|
+
'<div class="per-project-label">' + escapeHtml(p) + '</div>' +
|
|
12008
|
+
'<div class="per-project-chart"><div class="pp-empty">Loading\u2026</div></div>' +
|
|
12009
|
+
'</div>'
|
|
12010
|
+
)).join('');
|
|
12011
|
+
});
|
|
12012
|
+
}
|
|
12013
|
+
|
|
12014
|
+
function _setPerProjectStatus(text) {
|
|
12015
|
+
['daily-metrics-per-project-status', 'ratio-metrics-per-project-status'].forEach(id => {
|
|
12016
|
+
const el = document.getElementById(id);
|
|
12017
|
+
if (el) el.textContent = text || '';
|
|
12018
|
+
});
|
|
12019
|
+
}
|
|
12020
|
+
|
|
12021
|
+
// Lazy-load entry point. Triggered by details toggle and by filter changes
|
|
12022
|
+
// while at least one details is open. No-op if already loading or already
|
|
12023
|
+
// loaded for the current (granularity, platform) key.
|
|
12024
|
+
async function _perProjectLoadIfNeeded() {
|
|
12025
|
+
const want = _perProjectCacheKey();
|
|
12026
|
+
if (_perProjectState.loadedKey === want) {
|
|
12027
|
+
_renderPerProjectSkeleton();
|
|
12028
|
+
_renderPerProjectAll();
|
|
12029
|
+
return;
|
|
12030
|
+
}
|
|
12031
|
+
if (_perProjectState.loading) return;
|
|
12032
|
+
_perProjectState.loading = true;
|
|
12033
|
+
_perProjectState.loadedKey = want;
|
|
12034
|
+
_perProjectState.series = {};
|
|
12035
|
+
_perProjectState.failed = {};
|
|
12036
|
+
const gran = currentTrendsGranularity();
|
|
12037
|
+
const platform = currentTrendsPlatform();
|
|
12038
|
+
const fetchDays = gran === 'weekly' ? DAILY_METRICS_DAYS_WEEKLY : DAILY_METRICS_DAYS_DAILY;
|
|
12039
|
+
const today = new Date();
|
|
12040
|
+
const dailyAxis = [];
|
|
12041
|
+
for (let i = fetchDays - 1; i >= 0; i--) {
|
|
12042
|
+
const d = new Date(today);
|
|
12043
|
+
d.setDate(today.getDate() - i);
|
|
12044
|
+
dailyAxis.push(d.toISOString().slice(0, 10));
|
|
12045
|
+
}
|
|
12046
|
+
// Axis stored on state matches the granularity (weekly buckets or daily).
|
|
12047
|
+
if (gran === 'weekly') {
|
|
12048
|
+
const { weekKeys } = _bucketWeekly(dailyAxis, {});
|
|
12049
|
+
_perProjectState.days = weekKeys;
|
|
12050
|
+
} else {
|
|
12051
|
+
_perProjectState.days = dailyAxis;
|
|
12052
|
+
}
|
|
12053
|
+
_renderPerProjectSkeleton();
|
|
12054
|
+
const projects = _perProjectGetProjects();
|
|
12055
|
+
let done = 0;
|
|
12056
|
+
_setPerProjectStatus('0 / ' + projects.length);
|
|
12057
|
+
try {
|
|
12058
|
+
// 2 concurrent projects = 16 simultaneous endpoint calls (8 per project).
|
|
12059
|
+
// 3 was overloading the dashboard's pg pool and PostHog rate limits on a
|
|
12060
|
+
// cold cache; 2 keeps the per-project load slow-but-steady and lets the
|
|
12061
|
+
// pg pool (now max:25) actually drain between waves.
|
|
12062
|
+
await _perProjectRunConcurrency(projects, 2, async (project) => {
|
|
12063
|
+
// Bail if filters changed mid-load.
|
|
12064
|
+
if (_perProjectState.loadedKey !== want) return;
|
|
12065
|
+
const { series, failed } = await _perProjectFetchOne(project, gran, platform, fetchDays, dailyAxis);
|
|
12066
|
+
if (_perProjectState.loadedKey !== want) return;
|
|
12067
|
+
_perProjectState.series[project] = series;
|
|
12068
|
+
_perProjectState.failed[project] = failed;
|
|
12069
|
+
_renderPerProjectBoth(project);
|
|
12070
|
+
done += 1;
|
|
12071
|
+
_setPerProjectStatus(done + ' / ' + projects.length);
|
|
12072
|
+
});
|
|
12073
|
+
} finally {
|
|
12074
|
+
_perProjectState.loading = false;
|
|
12075
|
+
}
|
|
12076
|
+
// Final status: how many had any failed endpoint?
|
|
12077
|
+
const failedCount = Object.values(_perProjectState.failed).filter(arr => arr && arr.length).length;
|
|
12078
|
+
_setPerProjectStatus(projects.length + ' / ' + projects.length + (failedCount ? ' · ' + failedCount + ' partial' : ''));
|
|
12079
|
+
}
|
|
12080
|
+
|
|
12081
|
+
// Wire details toggle on first DOMContentLoaded. Idempotent so it survives
|
|
12082
|
+
// any partial reloads. Hook is delayed via setTimeout to make sure the
|
|
12083
|
+
// elements exist before we attach.
|
|
12084
|
+
function _wirePerProjectToggles() {
|
|
12085
|
+
['daily-metrics-per-project', 'ratio-metrics-per-project'].forEach(id => {
|
|
12086
|
+
const el = document.getElementById(id);
|
|
12087
|
+
if (!el || el.dataset.wired === '1') return;
|
|
12088
|
+
el.dataset.wired = '1';
|
|
12089
|
+
el.addEventListener('toggle', () => {
|
|
12090
|
+
if (el.open) _perProjectLoadIfNeeded();
|
|
12091
|
+
});
|
|
12092
|
+
});
|
|
12093
|
+
}
|
|
12094
|
+
|
|
11207
12095
|
// Trends-tab filter state. Selection is read off the trends pill rows by
|
|
11208
12096
|
// data-selected; granularity drives the day count and axis bucketing.
|
|
11209
12097
|
function currentTrendsPlatform() {
|
|
@@ -11279,13 +12167,14 @@ async function loadDailyMetrics() {
|
|
|
11279
12167
|
// or any transient 5xx) renders the affected series as flat zeros instead
|
|
11280
12168
|
// of killing the whole chart. The "Unable to load daily metrics" fallback
|
|
11281
12169
|
// below now only triggers when literally every fetch failed.
|
|
11282
|
-
// Per-endpoint timeout. /api/funnel/per-day shells out to PostHog
|
|
11283
|
-
//
|
|
11284
|
-
//
|
|
11285
|
-
// the
|
|
11286
|
-
//
|
|
11287
|
-
// the
|
|
11288
|
-
|
|
12170
|
+
// Per-endpoint timeout. /api/funnel/per-day shells out to PostHog HogQL
|
|
12171
|
+
// and at days=91 (weekly window) routinely takes 15-25s on a cold cache.
|
|
12172
|
+
// 9s was too tight: on a fresh page load the funnel endpoint timed out
|
|
12173
|
+
// and the top chart's Get Started / Visitors / Email Signups / Schedule
|
|
12174
|
+
// Clicks series all rendered as flat zeros. Bumped to 30s; paired with
|
|
12175
|
+
// the funnel pre-warmer (launchd com.m13v.social-funnel-prewarm) which
|
|
12176
|
+
// keeps the 5-min server cache hot so most fetches still return fast.
|
|
12177
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
11289
12178
|
const fetchOne = async (url) => {
|
|
11290
12179
|
const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
11291
12180
|
const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch {} }, FETCH_TIMEOUT_MS) : null;
|
|
@@ -11371,6 +12260,11 @@ async function loadDailyMetrics() {
|
|
|
11371
12260
|
.map(k => ({ key: k, timedOut: !!fetchResults[k].timedOut }));
|
|
11372
12261
|
renderDailyMetrics();
|
|
11373
12262
|
renderRatioMetrics();
|
|
12263
|
+
// Propagate filter changes to the per-project breakdown. Cheap no-op
|
|
12264
|
+
// when neither section is open and the cache key (granularity+platform)
|
|
12265
|
+
// hasn't actually changed; otherwise it kicks off a fresh per-project
|
|
12266
|
+
// fetch in the background.
|
|
12267
|
+
try { _perProjectInvalidate(); } catch {}
|
|
11374
12268
|
} catch (e) {
|
|
11375
12269
|
if (chartEl) chartEl.innerHTML = '<div class="views-chart-empty">Unable to load daily metrics (' + escapeHtml(String(e.message || e)) + ').</div>';
|
|
11376
12270
|
}
|
|
@@ -11668,7 +12562,7 @@ let _styleStatsTableState = { sortField: 'score', sortDir: 'desc', filters: {} }
|
|
|
11668
12562
|
// OS-level title-attribute delay.
|
|
11669
12563
|
const STYLE_STATS_HELP = {
|
|
11670
12564
|
style: 'Engagement tone Claude used to draft this first-touch comment/post (slug from scripts/engagement_styles.py). The A/B testing system uses these stats to decide which tones to imitate next. Note: a row in the posts table = our FIRST-TOUCH engagement on a thread, not (usually) an original thread we authored. Reddit/Moltbook/GitHub = our top-level comment on someone else’s thread; X = our reply; LinkedIn = our comment. Subsequent back-and-forth replies live in a separate replies pipeline and are not counted here.',
|
|
11671
|
-
score: 'Per-post quality signal computed on engagement that landed on OUR comment/post (replies to it, upvotes on it), not on the underlying third-party thread. Formula: (comments * 3 + upvotes_discounted) / posts. upvotes_discounted subtracts the OP self-upvote on Reddit and Moltbook so those platforms compare fairly with X/LinkedIn. Views are deliberately excluded so low-volume styles compare fairly with high-volume ones.
|
|
12565
|
+
score: 'Per-post quality signal computed on engagement that landed on OUR comment/post (clicks on our short link, replies to it, upvotes on it), not on the underlying third-party thread. Formula: (post_clicks * 10 + comments * 3 + upvotes_discounted) / posts. Click weight ×10 because a real human click outvalues 10 likes of vibes (matches top_performers.SCORE_SQL and the engagement_styles.py picker). upvotes_discounted subtracts the OP self-upvote on Reddit and Moltbook so those platforms compare fairly with X/LinkedIn. Views are deliberately excluded so low-volume styles compare fairly with high-volume ones.',
|
|
11672
12566
|
posts: 'Count of first-touch comments/posts published in this style during the selected window. (Reddit comments on others’ threads, X replies, LinkedIn comments, etc. The rare run-reddit-threads.sh original-thread rows are also counted.)',
|
|
11673
12567
|
upvotes: 'Sum of upvotes/likes received by OUR comment (or our thread, in the rare original-thread case). Net of the Reddit/Moltbook OP self-upvote (both platforms auto-apply +1 on every post; we strip it so a brand-new post starts at 0, not 1). Other platforms (X, LinkedIn, GitHub) pass through unchanged. Per-post average shown in parentheses.',
|
|
11674
12568
|
comments: 'Sum of replies received by OUR comment (or comments under our thread). Per-post average in parentheses. Tracked in the posts.comments_count column, independent of the separate replies pipeline that records replies WE author.',
|
|
@@ -11715,17 +12609,31 @@ function formatStyleCell(name, metaMap) {
|
|
|
11715
12609
|
const m = (metaMap && metaMap[name]) || null;
|
|
11716
12610
|
if (!m || name === '(none)') return safeName;
|
|
11717
12611
|
const lines = [];
|
|
11718
|
-
|
|
11719
|
-
|
|
11720
|
-
if (m.
|
|
12612
|
+
// Bold the style name as the tooltip header.
|
|
12613
|
+
lines.push('**' + name + '**');
|
|
12614
|
+
if (m.description) {
|
|
12615
|
+
lines.push('');
|
|
12616
|
+
lines.push(m.description);
|
|
12617
|
+
}
|
|
12618
|
+
if (m.note) {
|
|
12619
|
+
lines.push('');
|
|
12620
|
+
lines.push('**Note:** ' + m.note);
|
|
12621
|
+
}
|
|
12622
|
+
if (m.why_existing_didnt_fit) {
|
|
12623
|
+
lines.push('');
|
|
12624
|
+
lines.push('**Why invented:** ' + m.why_existing_didnt_fit);
|
|
12625
|
+
}
|
|
11721
12626
|
const status = m.status || 'active';
|
|
11722
12627
|
const provenance = [];
|
|
11723
12628
|
if (status && status !== 'active') provenance.push('status=' + status);
|
|
11724
12629
|
if (m.invented_at) provenance.push('invented ' + String(m.invented_at).slice(0, 10));
|
|
11725
12630
|
if (m.first_post_platform) provenance.push('first on ' + m.first_post_platform);
|
|
11726
12631
|
if (m.promoted_at) provenance.push('promoted ' + String(m.promoted_at).slice(0, 10));
|
|
11727
|
-
if (provenance.length)
|
|
11728
|
-
|
|
12632
|
+
if (provenance.length) {
|
|
12633
|
+
lines.push('');
|
|
12634
|
+
lines.push(provenance.join(' · '));
|
|
12635
|
+
}
|
|
12636
|
+
if (lines.length <= 1) return safeName;
|
|
11729
12637
|
const tip = lines.join('\\n');
|
|
11730
12638
|
return '<span data-tooltip="' + escapeHtml(tip) + '" style="cursor: help; border-bottom: 1px dotted var(--text-muted);">' + safeName + '</span>';
|
|
11731
12639
|
}
|
|
@@ -11788,15 +12696,17 @@ function renderStyleStats(payload, meta) {
|
|
|
11788
12696
|
const per = total / denom;
|
|
11789
12697
|
return fmt(total) + ' <span style="color:var(--text-muted);">(' + perPostStr(per) + ')</span>';
|
|
11790
12698
|
};
|
|
11791
|
-
// Per-post score matches top_performers.SCORE_SQL
|
|
11792
|
-
//
|
|
11793
|
-
//
|
|
11794
|
-
//
|
|
12699
|
+
// Per-post score matches top_performers.SCORE_SQL and engagement_styles.py
|
|
12700
|
+
// picker (clicks*10 + comments*3 + upvotes, Reddit self-upvote discounted
|
|
12701
|
+
// at SQL layer). Click weight ×10: one real human click outvalues 10 likes
|
|
12702
|
+
// of vibes when ranking styles. Views deliberately excluded. Per-post avg
|
|
12703
|
+
// keeps low-volume styles on equal footing with high-volume ones.
|
|
11795
12704
|
const normalized = rows.map(r => {
|
|
11796
12705
|
const posts = Number(r.posts) || 0;
|
|
11797
12706
|
const comments = Number(r.comments) || 0;
|
|
11798
12707
|
const upvotesDiscounted = Number(r.upvotes_discounted) || 0;
|
|
11799
|
-
const
|
|
12708
|
+
const postClicks = Number(r.post_clicks) || 0;
|
|
12709
|
+
const score = posts > 0 ? (postClicks * 10 + comments * 3 + upvotesDiscounted) / posts : 0;
|
|
11800
12710
|
return {
|
|
11801
12711
|
style: r.style || '(none)',
|
|
11802
12712
|
posts,
|
|
@@ -11804,7 +12714,7 @@ function renderStyleStats(payload, meta) {
|
|
|
11804
12714
|
upvotes: Number(r.upvotes) || 0,
|
|
11805
12715
|
comments,
|
|
11806
12716
|
views: Number(r.views) || 0,
|
|
11807
|
-
post_clicks:
|
|
12717
|
+
post_clicks: postClicks,
|
|
11808
12718
|
recommendations: Number(r.recommendations) || 0,
|
|
11809
12719
|
score,
|
|
11810
12720
|
};
|
|
@@ -11886,13 +12796,15 @@ async function loadStyleStats() {
|
|
|
11886
12796
|
}
|
|
11887
12797
|
|
|
11888
12798
|
// Score-cohort distribution. Buckets posts in the trailing window into
|
|
11889
|
-
// 4 cohorts (dead/low/mid/high) by composite score (comments*3 +
|
|
11890
|
-
// minus 1 on Reddit/Moltbook to strip the OP self-upvote). Mirrors
|
|
12799
|
+
// 4 cohorts (dead/low/mid/high) by composite score (clicks*10 + comments*3 +
|
|
12800
|
+
// upvotes, minus 1 on Reddit/Moltbook to strip the OP self-upvote). Mirrors
|
|
12801
|
+
// top_performers.SCORE_SQL and the engagement_styles.py picker. Bands
|
|
12802
|
+
// rescaled to absorb the click ×10 weight (a single click already adds 10).
|
|
11891
12803
|
const COHORT_DEFS = [
|
|
11892
|
-
{ key: 'dead', label: 'Dead', scoreLabel: '0', blurb: 'No discussion, no upvotes beyond the OP self-upvote (Reddit/Moltbook). Skip imitating.' },
|
|
11893
|
-
{ key: 'low', label: 'Low', scoreLabel: '1\
|
|
11894
|
-
{ key: 'mid', label: 'Mid', scoreLabel: '
|
|
11895
|
-
{ key: 'high', label: 'High', scoreLabel: '
|
|
12804
|
+
{ key: 'dead', label: 'Dead', scoreLabel: '0', blurb: 'No discussion, no upvotes beyond the OP self-upvote (Reddit/Moltbook), no clicks. Skip imitating.' },
|
|
12805
|
+
{ key: 'low', label: 'Low', scoreLabel: '1\u20139', blurb: 'A handful of upvotes OR a couple of comments, but no real clicks. Faint signal.' },
|
|
12806
|
+
{ key: 'mid', label: 'Mid', scoreLabel: '10\u201329', blurb: 'A single real click (×10), OR strong discussion (3+ comments), OR 10\u201329 upvotes. Worth imitating.' },
|
|
12807
|
+
{ key: 'high', label: 'High', scoreLabel: '30+', blurb: 'Multiple clicks plus discussion, or a viral comment thread. Imitate these.' },
|
|
11896
12808
|
];
|
|
11897
12809
|
const COHORT_COLORS = { dead: '#9ca3af', low: '#60a5fa', mid: '#22c55e', high: '#a855f7' };
|
|
11898
12810
|
|
|
@@ -11932,7 +12844,7 @@ function renderCohortStats(payload) {
|
|
|
11932
12844
|
const headers = [
|
|
11933
12845
|
{ label: 'Cohort', tip: 'Bucket name. Tooltip on each row explains what that range means.' },
|
|
11934
12846
|
{ label: 'Posts', tip: 'Number of posts in the bucket (and share of total in the current window/platform/project filter).' },
|
|
11935
|
-
{ label: 'Score', tip: 'Composite score = comments \u00D7 3 + upvotes (Reddit/Moltbook subtract 1 to strip OP self-upvote). Range and average within this cohort.' },
|
|
12847
|
+
{ label: 'Score', tip: 'Composite score = clicks \u00D7 10 + comments \u00D7 3 + upvotes (Reddit/Moltbook subtract 1 to strip OP self-upvote). Click weight \u00D710 matches top_performers.SCORE_SQL and the engagement_styles.py picker. Range and average within this cohort.' },
|
|
11936
12848
|
{ label: 'Upvotes', tip: 'Upvote/like/reaction count range (min\u2013max) and average within this cohort. Raw, before the Reddit/Moltbook self-upvote discount.' },
|
|
11937
12849
|
{ label: 'Comments', tip: 'Reply/comment count range (min\u2013max) and average within this cohort.' },
|
|
11938
12850
|
{ label: 'Views', tip: 'View count range (min\u2013max) and average within this cohort. Excludes Moltbook and GitHub since those platforms do not expose view counts.' },
|
|
@@ -12479,7 +13391,7 @@ function renderSearchQueriesStats(payload) {
|
|
|
12479
13391
|
formatter: (v, row) => {
|
|
12480
13392
|
const n = Number(v) || 0;
|
|
12481
13393
|
if (row.serp_quality_avg != null) {
|
|
12482
|
-
const tip = '
|
|
13394
|
+
const tip = '**Avg SERP quality** (LinkedIn only): **' + row.serp_quality_avg.toFixed(1) + '/10**';
|
|
12483
13395
|
return '<span data-tooltip="' + escapeHtml(tip) + '">' + fmt(n) + '</span>';
|
|
12484
13396
|
}
|
|
12485
13397
|
return fmt(n);
|
|
@@ -12487,7 +13399,7 @@ function renderSearchQueriesStats(payload) {
|
|
|
12487
13399
|
{ key: 'dud_rate', label: 'Dud %', type: 'numeric', align: 'right', widthPct: 5,
|
|
12488
13400
|
formatter: (v, row) => {
|
|
12489
13401
|
const n = Number(v) || 0;
|
|
12490
|
-
const tip = (row.dud_attempts || 0) + ' of ' + (row.attempts || 0) + ' attempts returned 0';
|
|
13402
|
+
const tip = '**' + (row.dud_attempts || 0) + '** of **' + (row.attempts || 0) + '** attempts returned 0';
|
|
12491
13403
|
const color = n >= 0.5 ? 'var(--danger,#dc2626)' : (n >= 0.25 ? 'var(--warn,#d97706)' : 'var(--text-secondary)');
|
|
12492
13404
|
return '<span data-tooltip="' + escapeHtml(tip) + '" style="color:' + color + ';font-variant-numeric:tabular-nums;">' + pct(n) + '</span>';
|
|
12493
13405
|
} },
|
|
@@ -12500,13 +13412,15 @@ function renderSearchQueriesStats(payload) {
|
|
|
12500
13412
|
{ key: 'avg_engagement', label: 'Avg Eng', type: 'numeric', align: 'right', widthPct: 4,
|
|
12501
13413
|
formatter: v => {
|
|
12502
13414
|
if (v == null) return '<span style="color:var(--text-faint);">\u2014</span>';
|
|
12503
|
-
const tip = 'comments\u00D73 + upvotes on our reply
|
|
13415
|
+
const tip = '**Formula:** comments\u00D73 + upvotes on our reply' + String.fromCharCode(10) +
|
|
13416
|
+
'(same as top_performers.py)';
|
|
12504
13417
|
return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt1(v) + '</span>';
|
|
12505
13418
|
} },
|
|
12506
13419
|
{ key: 'avg_views', label: 'Avg Views', type: 'numeric', align: 'right', widthPct: 4,
|
|
12507
13420
|
formatter: v => {
|
|
12508
13421
|
if (v == null) return '<span data-tooltip="LinkedIn does not expose comment views" style="color:var(--text-faint);">\u2014</span>';
|
|
12509
|
-
const tip = '
|
|
13422
|
+
const tip = '**Avg view count** on our Twitter reply' + String.fromCharCode(10) +
|
|
13423
|
+
'(raw, not weighted into Avg Eng)';
|
|
12510
13424
|
return '<span data-tooltip="' + escapeHtml(tip) + '" style="font-variant-numeric:tabular-nums;">' + fmt(Math.round(v)) + '</span>';
|
|
12511
13425
|
} },
|
|
12512
13426
|
{ key: 'last_run', label: 'Last Run', type: 'numeric', align: 'right', widthPct: 4,
|
|
@@ -12566,11 +13480,11 @@ function renderCostStats(payload) {
|
|
|
12566
13480
|
if (totalEl) {
|
|
12567
13481
|
totalEl.textContent = '$' + totalCost.toFixed(2) + ' · ' + totalCount.toLocaleString() + ' activit' + (totalCount === 1 ? 'y' : 'ies');
|
|
12568
13482
|
const tipLines = [
|
|
12569
|
-
'Cost (SDK orchestrator)
|
|
13483
|
+
'**Cost (SDK orchestrator):** $' + totalOrch.toFixed(4),
|
|
12570
13484
|
'',
|
|
12571
|
-
'Diagnostic-only (local pricing estimate, not actual billing)
|
|
12572
|
-
'
|
|
12573
|
-
'
|
|
13485
|
+
'**Diagnostic-only** (local pricing estimate, not actual billing)',
|
|
13486
|
+
'• Transcript estimate: $' + totalEst.toFixed(4),
|
|
13487
|
+
'• Subagent (est): $' + totalSub.toFixed(4),
|
|
12574
13488
|
'',
|
|
12575
13489
|
'Anthropic SDK-reported orchestrator_cost_usd. Pipelines whose wrappers don\\'t capture --orchestrator-cost-usd contribute $0 — see the per-phase table for which scripts are missing coverage.',
|
|
12576
13490
|
];
|
|
@@ -12587,11 +13501,11 @@ function renderCostStats(payload) {
|
|
|
12587
13501
|
function fmtCount(v) { return (Number(v) || 0).toLocaleString(); }
|
|
12588
13502
|
function moneyCell(displayed, orch, est, sub) {
|
|
12589
13503
|
const tip = [
|
|
12590
|
-
'Cost (SDK orchestrator)
|
|
13504
|
+
'**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
|
|
12591
13505
|
'',
|
|
12592
|
-
'Diagnostic-only (local pricing estimate)
|
|
12593
|
-
'
|
|
12594
|
-
'
|
|
13506
|
+
'**Diagnostic-only** (local pricing estimate)',
|
|
13507
|
+
'• Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
|
|
13508
|
+
'• Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
|
|
12595
13509
|
].join('\\n');
|
|
12596
13510
|
return '<span data-tooltip="' + escapeHtml(tip) +
|
|
12597
13511
|
'" style="cursor:help;border-bottom:1px dotted var(--text-muted);">' +
|
|
@@ -13129,7 +14043,15 @@ function renderTopPosts(payload) {
|
|
|
13129
14043
|
let clickLine = '';
|
|
13130
14044
|
if (hasLink) {
|
|
13131
14045
|
if (havePerClick) {
|
|
13132
|
-
const tip =
|
|
14046
|
+
const tip =
|
|
14047
|
+
'**Clicks: humans / bots**' + String.fromCharCode(10) +
|
|
14048
|
+
String.fromCharCode(10) +
|
|
14049
|
+
'Split from post_link_clicks (UA bot regex).' + String.fromCharCode(10) +
|
|
14050
|
+
String.fromCharCode(10) +
|
|
14051
|
+
'• **Real clicks** = the human number' + String.fromCharCode(10) +
|
|
14052
|
+
'• **Bots** = Twitter card / LinkedIn unfurl / Slack preview prefetches' + String.fromCharCode(10) +
|
|
14053
|
+
String.fromCharCode(10) +
|
|
14054
|
+
'**CTR** = humans / views';
|
|
13133
14055
|
clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
|
|
13134
14056
|
+ '<span class="top-stats-k">clicks</span>'
|
|
13135
14057
|
+ real
|
|
@@ -13137,7 +14059,14 @@ function renderTopPosts(payload) {
|
|
|
13137
14059
|
+ ' <span style="color:var(--text-muted);">/ ' + bots + '</span>'
|
|
13138
14060
|
+ '</span>';
|
|
13139
14061
|
} else if (backfill > 0) {
|
|
13140
|
-
const tip =
|
|
14062
|
+
const tip =
|
|
14063
|
+
'**Clicks (estimated)**' + String.fromCharCode(10) +
|
|
14064
|
+
String.fromCharCode(10) +
|
|
14065
|
+
'Estimated from PostHog $pageview events with matching utm_content. Real humans only — bots already filtered by PostHog.' + String.fromCharCode(10) +
|
|
14066
|
+
String.fromCharCode(10) +
|
|
14067
|
+
'Pre 2026-05-07 row, no per-click log; backfilled by scripts/backfill_real_clicks.py.' + String.fromCharCode(10) +
|
|
14068
|
+
String.fromCharCode(10) +
|
|
14069
|
+
'**CTR** = backfill / views';
|
|
13141
14070
|
clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
|
|
13142
14071
|
+ '<span class="top-stats-k">clicks</span>'
|
|
13143
14072
|
+ backfill
|
|
@@ -13145,7 +14074,14 @@ function renderTopPosts(payload) {
|
|
|
13145
14074
|
+ ' <span style="color:var(--text-muted);">(estimated)</span>'
|
|
13146
14075
|
+ '</span>';
|
|
13147
14076
|
} else if (legacy > 0) {
|
|
13148
|
-
const tip =
|
|
14077
|
+
const tip =
|
|
14078
|
+
'**Clicks (legacy)** — pre 2026-05-07' + String.fromCharCode(10) +
|
|
14079
|
+
String.fromCharCode(10) +
|
|
14080
|
+
'Twitter card / LinkedIn / Slack preview bots inflated this ~20×.' + String.fromCharCode(10) +
|
|
14081
|
+
String.fromCharCode(10) +
|
|
14082
|
+
'New clicks split humans/bots in post_link_clicks. Destination domain has no PostHog so we cannot backfill the real number.' + String.fromCharCode(10) +
|
|
14083
|
+
String.fromCharCode(10) +
|
|
14084
|
+
'**CTR** = legacy / views (also inflated)';
|
|
13149
14085
|
clickLine = '<span class="top-stats-bit" data-tooltip="' + escapeHtml(tip) + '">'
|
|
13150
14086
|
+ '<span class="top-stats-k">clicks</span>'
|
|
13151
14087
|
+ legacy
|
|
@@ -14272,7 +15208,11 @@ function renderTopDms(payload) {
|
|
|
14272
15208
|
clickLine = '<div class="dm-stat-line dm-stat-line-muted"><span class="dm-stat-num">—</span> <span class="dm-stat-label">clicks</span></div>';
|
|
14273
15209
|
} else {
|
|
14274
15210
|
const lastAt = r.short_link_last_click_at ? new Date(r.short_link_last_click_at).toLocaleString() : 'never';
|
|
14275
|
-
const
|
|
15211
|
+
const tipNL = String.fromCharCode(10);
|
|
15212
|
+
const tip = '**Short link** /r/' + String(r.short_link_code) + tipNL +
|
|
15213
|
+
(clicks
|
|
15214
|
+
? tipNL + '**Last click:** ' + lastAt
|
|
15215
|
+
: tipNL + 'No clicks yet');
|
|
14276
15216
|
const color = clicks > 0 ? 'var(--accent)' : 'var(--text-muted)';
|
|
14277
15217
|
clickLine = '<div class="dm-stat-line" data-tooltip="' + escapeHtml(tip) + '" style="color:' + color + ';"><span class="dm-stat-num" style="font-variant-numeric:tabular-nums;">' + fmt(clicks) + '</span> <span class="dm-stat-label">clicks</span></div>';
|
|
14278
15218
|
}
|
|
@@ -14282,7 +15222,14 @@ function renderTopDms(payload) {
|
|
|
14282
15222
|
bookedLine = '<div class="dm-stat-line dm-stat-line-muted"><span class="dm-stat-num">—</span> <span class="dm-stat-label">booked</span></div>';
|
|
14283
15223
|
} else {
|
|
14284
15224
|
const lastAt = r.last_booking_at ? new Date(r.last_booking_at).toLocaleString() : '';
|
|
14285
|
-
const
|
|
15225
|
+
const bNL = String.fromCharCode(10);
|
|
15226
|
+
const tipParts = ['**Bookings**', '', '• **Booked:** ' + booked];
|
|
15227
|
+
if (cancelled) tipParts.push('• **Cancelled:** ' + cancelled);
|
|
15228
|
+
if (lastAt) {
|
|
15229
|
+
tipParts.push('');
|
|
15230
|
+
tipParts.push('**Last booking:** ' + lastAt);
|
|
15231
|
+
}
|
|
15232
|
+
const tip = tipParts.join(bNL);
|
|
14286
15233
|
bookedLine = '<div class="dm-stat-line" data-tooltip="' + escapeHtml(tip) + '" style="color:var(--success);font-weight:600;"><span class="dm-stat-num" style="font-variant-numeric:tabular-nums;">' + fmt(booked) + '</span> <span class="dm-stat-label">booked</span></div>';
|
|
14287
15234
|
}
|
|
14288
15235
|
|
|
@@ -14295,13 +15242,19 @@ function renderTopDms(payload) {
|
|
|
14295
15242
|
const wrapped = !!(r.booking_link_sent_at || linkCount > 0);
|
|
14296
15243
|
const detected = !!r.outbound_url_detected;
|
|
14297
15244
|
if (!wrapped && !detected) return '<span style="color:var(--text-faint);">No</span>';
|
|
14298
|
-
const tipParts = [];
|
|
14299
|
-
if (r.booking_link_sent_at) tipParts.push('
|
|
14300
|
-
if (linkCount > 0) tipParts.push(linkCount + ' wrapped link' + (linkCount === 1 ? '' : 's'));
|
|
14301
|
-
if (r.short_link_code) tipParts.push('
|
|
14302
|
-
if (!wrapped && detected)
|
|
14303
|
-
|
|
14304
|
-
|
|
15245
|
+
const tipParts = ['**Link tracking**', ''];
|
|
15246
|
+
if (r.booking_link_sent_at) tipParts.push('• **Booking link sent:** ' + new Date(r.booking_link_sent_at).toLocaleString());
|
|
15247
|
+
if (linkCount > 0) tipParts.push('• **' + linkCount + ' wrapped link' + (linkCount === 1 ? '' : 's') + '**');
|
|
15248
|
+
if (r.short_link_code) tipParts.push('• **Latest:** /r/' + String(r.short_link_code));
|
|
15249
|
+
if (!wrapped && detected) {
|
|
15250
|
+
tipParts.push('');
|
|
15251
|
+
tipParts.push('**Raw URL detected** in outbound text');
|
|
15252
|
+
tipParts.push('(wrap pipeline bypassed — no dm_links row, click tracking missing)');
|
|
15253
|
+
} else if (detected && wrapped) {
|
|
15254
|
+
tipParts.push('');
|
|
15255
|
+
tipParts.push('Also: raw URL in outbound text');
|
|
15256
|
+
}
|
|
15257
|
+
const tip = tipParts.join(String.fromCharCode(10));
|
|
14305
15258
|
const tipAttr = tip ? ' data-tooltip="' + escapeHtml(tip) + '"' : '';
|
|
14306
15259
|
const label = wrapped ? 'Yes' : 'Yes*';
|
|
14307
15260
|
const color = wrapped ? 'var(--success)' : '#b45309';
|
|
@@ -15111,11 +16064,11 @@ function renderProjectStatus(data, opts) {
|
|
|
15111
16064
|
// SDK-only mode: displayed value comes from orchestrator_cost_usd; the
|
|
15112
16065
|
// estimate and subagent are diagnostic-only (local pricing table).
|
|
15113
16066
|
const tip = [
|
|
15114
|
-
'Cost (SDK orchestrator)
|
|
16067
|
+
'**Cost (SDK orchestrator):** ' + (orch != null ? fmtMoney(orch) : 'n/a'),
|
|
15115
16068
|
'',
|
|
15116
|
-
'Diagnostic-only (local pricing estimate, not actual billing)
|
|
15117
|
-
'
|
|
15118
|
-
'
|
|
16069
|
+
'**Diagnostic-only** (local pricing estimate, not actual billing)',
|
|
16070
|
+
'• Transcript estimate: ' + (est != null ? fmtMoney(est) : 'n/a'),
|
|
16071
|
+
'• Subagent (est): ' + (sub != null ? fmtMoney(sub) : 'n/a'),
|
|
15119
16072
|
'',
|
|
15120
16073
|
'Anthropic SDK-reported cost only. "n/a" or $0 means the wrapper didn\\'t capture --orchestrator-cost-usd (no --output-format json on the claude call) for one or more sessions in this window. Subagent and transcript estimates are computed from a local pricing table and are not billing-accurate on subscription plans.',
|
|
15121
16074
|
].join('\\n');
|
|
@@ -15133,11 +16086,11 @@ function renderProjectStatus(data, opts) {
|
|
|
15133
16086
|
: base;
|
|
15134
16087
|
if (costAvailable) {
|
|
15135
16088
|
const tipLines = [
|
|
15136
|
-
'Cost (SDK orchestrator)
|
|
16089
|
+
'**Cost (SDK orchestrator):** ' + fmtMoney(grandCostOrch),
|
|
15137
16090
|
'',
|
|
15138
|
-
'Diagnostic-only (local pricing estimate, not actual billing)
|
|
15139
|
-
'
|
|
15140
|
-
'
|
|
16091
|
+
'**Diagnostic-only** (local pricing estimate, not actual billing)',
|
|
16092
|
+
'• Transcript estimate: ' + fmtMoney(grandCostEst),
|
|
16093
|
+
'• Subagent (est): ' + fmtMoney(grandCostSub),
|
|
15141
16094
|
'',
|
|
15142
16095
|
'Anthropic SDK-reported orchestrator_cost_usd across all activity rows in this window. Per-session attribution is the session\\'s cost split evenly across its activity rows. Pipelines whose wrappers don\\'t pass --output-format json to claude contribute $0.',
|
|
15143
16096
|
];
|
|
@@ -15412,7 +16365,11 @@ async function loadFunnelStats(force) {
|
|
|
15412
16365
|
if (_funnelStatsLoading) return;
|
|
15413
16366
|
if (saAuthNotReady()) return;
|
|
15414
16367
|
const days = currentStatsWindow().days;
|
|
15415
|
-
|
|
16368
|
+
const platform = currentStatsPlatform();
|
|
16369
|
+
// Cache key is window+platform so a platform-pill change forces a refetch
|
|
16370
|
+
// even when the days dropdown stayed the same. Matches the server's cache key.
|
|
16371
|
+
const loadKey = days + '|' + platform;
|
|
16372
|
+
if (_funnelStatsLoadedFor === loadKey && !force) return;
|
|
15416
16373
|
_funnelStatsLoading = true;
|
|
15417
16374
|
const totalEl = document.getElementById('funnel-stats-total');
|
|
15418
16375
|
const body = document.getElementById('funnel-stats-body');
|
|
@@ -15421,11 +16378,13 @@ async function loadFunnelStats(force) {
|
|
|
15421
16378
|
body.innerHTML = '<div class="style-stats-empty">Loading\u2026 (first call can take 15\u201330s)</div>';
|
|
15422
16379
|
}
|
|
15423
16380
|
try {
|
|
15424
|
-
const
|
|
16381
|
+
const params = ['days=' + days];
|
|
16382
|
+
if (platform && platform !== 'all') params.push('platform=' + encodeURIComponent(platform));
|
|
16383
|
+
const res = await fetch('/api/funnel/stats?' + params.join('&'));
|
|
15425
16384
|
const data = await res.json();
|
|
15426
16385
|
if (data && !data.error) _lastFunnelPayload = data;
|
|
15427
16386
|
renderFunnelStats(data);
|
|
15428
|
-
_funnelStatsLoadedFor =
|
|
16387
|
+
_funnelStatsLoadedFor = loadKey;
|
|
15429
16388
|
} catch (e) {
|
|
15430
16389
|
if (body) body.innerHTML = '<div class="style-stats-empty">Failed to load.</div>';
|
|
15431
16390
|
} finally {
|
|
@@ -15808,6 +16767,15 @@ _saInstallDeleteListener();
|
|
|
15808
16767
|
});
|
|
15809
16768
|
})();
|
|
15810
16769
|
|
|
16770
|
+
// Wire the per-project breakdown <details> toggles so the first time the
|
|
16771
|
+
// user opens either one we kick off a lazy fetch. Subsequent opens reuse
|
|
16772
|
+
// the cache built for the current (granularity, platform) key.
|
|
16773
|
+
(function wirePerProjectBreakdownToggles() {
|
|
16774
|
+
if (typeof _wirePerProjectToggles === 'function') {
|
|
16775
|
+
_wirePerProjectToggles();
|
|
16776
|
+
}
|
|
16777
|
+
})();
|
|
16778
|
+
|
|
15811
16779
|
// Lazy-load funnel stats the first time the user opens the section. The fetch
|
|
15812
16780
|
// shells out to PostHog and two Postgres DBs, so we don't want to run it on
|
|
15813
16781
|
// every page load.
|