social-autoposter 1.3.4 → 1.3.6
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 +384 -38
- package/package.json +1 -1
- package/scripts/active_campaigns.py +43 -3
- package/scripts/campaign_bump.py +34 -11
- package/scripts/check_external_pool_depth.py +256 -0
- package/scripts/daily_stats_email.py +502 -171
- package/scripts/dm_short_links.py +128 -49
- package/scripts/engage_reddit.py +14 -3
- package/scripts/get_run_cost.py +56 -27
- package/scripts/mint_external_pool.py +6 -1
- package/scripts/mint_kent_pool.py +3 -1
- package/scripts/pick_project.py +38 -17
- package/scripts/post_github.py +9 -3
- package/scripts/post_reddit.py +9 -3
- package/scripts/seed_dashboard_users.py +94 -0
- package/scripts/top_performers.py +122 -62
- package/scripts/twitter_browser.py +98 -53
- package/scripts/twitter_post_plan.py +10 -3
package/bin/server.js
CHANGED
|
@@ -1077,6 +1077,20 @@ async function enrichPostCommentsLinkedInRuns(runs) {
|
|
|
1077
1077
|
// posted = COUNT(*) twitter_candidates status='posted' in window
|
|
1078
1078
|
// pending = global COUNT(*) status='pending' (small for Twitter; each cycle
|
|
1079
1079
|
// self-expires its own batch)
|
|
1080
|
+
// Parse a twitter cycle batch_id (`twcycle-YYYYMMDD-HHMMSS`) into epoch ms.
|
|
1081
|
+
// Used to map each candidate/search row back to the cycle that owns it, so a
|
|
1082
|
+
// long-running cycle's posts aren't also attributed to the next cycle's run
|
|
1083
|
+
// window (the prior overlap-based attribution triple-counted posts when three
|
|
1084
|
+
// cycles were live simultaneously, e.g. 2026-05-14 16:13/16:15/16:30 all
|
|
1085
|
+
// reported posted=8/8/4 for the same 8 unique posts).
|
|
1086
|
+
function parseTwitterBatchIdMs(batchId) {
|
|
1087
|
+
if (!batchId) return NaN;
|
|
1088
|
+
const m = batchId.match(/^twcycle-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
|
|
1089
|
+
if (!m) return NaN;
|
|
1090
|
+
const [, y, mo, d, hh, mm, ss] = m;
|
|
1091
|
+
return new Date(`${y}-${mo}-${d}T${hh}:${mm}:${ss}`).getTime();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1080
1094
|
async function enrichPostCommentsTwitterRuns(runs) {
|
|
1081
1095
|
const txRuns = runs.filter(r =>
|
|
1082
1096
|
r.job_type === 'post-comments' && r.platform_key === 'twitter'
|
|
@@ -1088,6 +1102,19 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1088
1102
|
if (ms < oldestMs) oldestMs = ms;
|
|
1089
1103
|
}
|
|
1090
1104
|
const since = new Date(oldestMs - 2 * 60 * 1000).toISOString();
|
|
1105
|
+
// Cycle log files (`twitter-cycle-YYYY-MM-DD_HHMMSS.log`) carry the Phase 0
|
|
1106
|
+
// salvage marker we need for the salvaged pill. Read once per enricher call.
|
|
1107
|
+
let logFiles = [];
|
|
1108
|
+
try {
|
|
1109
|
+
logFiles = fs.readdirSync(LOG_DIR).filter(f => f.startsWith('twitter-cycle-') && f.endsWith('.log'));
|
|
1110
|
+
} catch { /* empty */ }
|
|
1111
|
+
const cycleFileTs = (name) => {
|
|
1112
|
+
const m = name.match(/^twitter-cycle-(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})(\d{2})\.log$/);
|
|
1113
|
+
if (!m) return NaN;
|
|
1114
|
+
const [, day, hh, mm, ss] = m;
|
|
1115
|
+
return new Date(`${day}T${hh}:${mm}:${ss}`).getTime();
|
|
1116
|
+
};
|
|
1117
|
+
const phase0SalvageRe = /Phase 0: salvaged (\d+) orphaned pending rows/;
|
|
1091
1118
|
const searchRows = await pq(
|
|
1092
1119
|
"SELECT ran_at, tweets_found, batch_id FROM twitter_search_attempts " +
|
|
1093
1120
|
"WHERE ran_at >= $1::timestamp",
|
|
@@ -1173,22 +1200,78 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1173
1200
|
for (const run of txRuns) {
|
|
1174
1201
|
const startMs = new Date(run.started_at).getTime();
|
|
1175
1202
|
const endMs = new Date(run.finished_at).getTime() + 60 * 1000;
|
|
1203
|
+
// Attribute funnel counters to the ONE batch this run owns — derived by
|
|
1204
|
+
// matching `twcycle-YYYYMMDD-HHMMSS` to run.started_at within ±10s. The
|
|
1205
|
+
// prior code attributed every batch whose search_attempt fell in this
|
|
1206
|
+
// window, which triple-counted posts under overlapping cycles.
|
|
1207
|
+
let ownBatchId = null;
|
|
1208
|
+
let ownBatchDelta = Infinity;
|
|
1209
|
+
for (const s of searchNorm) {
|
|
1210
|
+
if (!s.batch_id) continue;
|
|
1211
|
+
const bms = parseTwitterBatchIdMs(s.batch_id);
|
|
1212
|
+
if (!Number.isFinite(bms)) continue;
|
|
1213
|
+
const delta = Math.abs(bms - startMs);
|
|
1214
|
+
if (delta > 10 * 1000) continue;
|
|
1215
|
+
if (delta < ownBatchDelta) { ownBatchDelta = delta; ownBatchId = s.batch_id; }
|
|
1216
|
+
}
|
|
1217
|
+
// Fall back to scanning candidate rows if no search_attempt matched (e.g.
|
|
1218
|
+
// Phase 1 logged nothing because the cycle aborted before scraping).
|
|
1219
|
+
if (!ownBatchId) {
|
|
1220
|
+
for (const c of candNorm) {
|
|
1221
|
+
if (!c.batch_id) continue;
|
|
1222
|
+
const bms = parseTwitterBatchIdMs(c.batch_id);
|
|
1223
|
+
if (!Number.isFinite(bms)) continue;
|
|
1224
|
+
const delta = Math.abs(bms - startMs);
|
|
1225
|
+
if (delta > 10 * 1000) continue;
|
|
1226
|
+
if (delta < ownBatchDelta) { ownBatchDelta = delta; ownBatchId = c.batch_id; }
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1176
1229
|
let searches = 0, candidatesRaw = 0, posted = 0, expired = 0;
|
|
1177
|
-
const batchIds = new Set();
|
|
1178
1230
|
for (const s of searchNorm) {
|
|
1179
1231
|
if (s.ms == null || s.ms < startMs || s.ms > endMs) continue;
|
|
1232
|
+
if (!ownBatchId || s.batch_id !== ownBatchId) continue;
|
|
1180
1233
|
searches++;
|
|
1181
1234
|
candidatesRaw += s.found;
|
|
1182
|
-
if (s.batch_id) batchIds.add(s.batch_id);
|
|
1183
1235
|
}
|
|
1184
1236
|
let candidatesPassed = 0;
|
|
1237
|
+
let salvagePosted = 0;
|
|
1185
1238
|
for (const c of candNorm) {
|
|
1186
|
-
if (!
|
|
1239
|
+
if (!ownBatchId || c.batch_id !== ownBatchId) continue;
|
|
1187
1240
|
candidatesPassed++;
|
|
1188
|
-
if (c.status === 'posted')
|
|
1189
|
-
|
|
1241
|
+
if (c.status === 'posted') {
|
|
1242
|
+
posted++;
|
|
1243
|
+
// Salvage signature: candidate's discovered_at predates this cycle's
|
|
1244
|
+
// start by enough that it must have been pulled in by Phase 0 (which
|
|
1245
|
+
// rewrites batch_id to the current BATCH_ID). Tolerance covers Phase 1
|
|
1246
|
+
// re-discovery via SERP repeat (very fast — <run_duration window).
|
|
1247
|
+
if (c.discoveredMs != null && c.discoveredMs < startMs - 30 * 60 * 1000) {
|
|
1248
|
+
salvagePosted++;
|
|
1249
|
+
}
|
|
1250
|
+
} else if (c.status === 'expired') expired++;
|
|
1190
1251
|
}
|
|
1191
1252
|
const candidatesDropped = Math.max(0, candidatesRaw - candidatesPassed);
|
|
1253
|
+
// Phase 0 salvage attempt count from the cycle log — what was actually
|
|
1254
|
+
// pulled in (vs `salvageable_now`, which is the future pool). Surfaces in
|
|
1255
|
+
// the salvaged pill so it shows what THIS cycle salvaged, not what NEXT
|
|
1256
|
+
// cycle could salvage.
|
|
1257
|
+
let salvageAttempted = 0;
|
|
1258
|
+
let chosenLog = null;
|
|
1259
|
+
let chosenLogDelta = Infinity;
|
|
1260
|
+
for (const f of logFiles) {
|
|
1261
|
+
const ts = cycleFileTs(f);
|
|
1262
|
+
if (!Number.isFinite(ts)) continue;
|
|
1263
|
+
if (ts > startMs + 90 * 1000) continue;
|
|
1264
|
+
if (ts < startMs - 90 * 1000) continue;
|
|
1265
|
+
const delta = Math.abs(ts - startMs);
|
|
1266
|
+
if (delta < chosenLogDelta) { chosenLogDelta = delta; chosenLog = f; }
|
|
1267
|
+
}
|
|
1268
|
+
if (chosenLog) {
|
|
1269
|
+
try {
|
|
1270
|
+
const body = fs.readFileSync(path.join(LOG_DIR, chosenLog), 'utf8');
|
|
1271
|
+
const m = body.match(phase0SalvageRe);
|
|
1272
|
+
if (m) salvageAttempted = parseInt(m[1], 10);
|
|
1273
|
+
} catch { /* empty */ }
|
|
1274
|
+
}
|
|
1192
1275
|
// Per-run queue delta. ADD = candidates whose discovered_at fell in this
|
|
1193
1276
|
// run's window (Phase 1 SERP discovery wrote them into twitter_candidates
|
|
1194
1277
|
// as 'pending'). DRAIN = candidates that left 'pending' inside the same
|
|
@@ -1282,6 +1365,14 @@ async function enrichPostCommentsTwitterRuns(runs) {
|
|
|
1282
1365
|
salvageable_now: salvageableNow,
|
|
1283
1366
|
salvageable_added: salvAdded,
|
|
1284
1367
|
salvageable_drained: salvDrained,
|
|
1368
|
+
// Actual salvage performed by THIS cycle's Phase 0. Read from the
|
|
1369
|
+
// matching cycle log (`twitter-cycle-*.log`); falls back to 0 when the
|
|
1370
|
+
// log was rotated. salvage_posted counts posted candidates whose
|
|
1371
|
+
// discovered_at predates the cycle by >30min (those rows can only be
|
|
1372
|
+
// here via Phase 0 salvage rewriting their batch_id).
|
|
1373
|
+
salvage_attempted: salvageAttempted,
|
|
1374
|
+
salvage_posted: salvagePosted,
|
|
1375
|
+
own_batch_id: ownBatchId,
|
|
1285
1376
|
cost_usd: prior.cost_usd || 0,
|
|
1286
1377
|
failed: prior.failed || 0,
|
|
1287
1378
|
failure_reasons: Array.isArray(prior.failure_reasons) ? prior.failure_reasons : [],
|
|
@@ -5438,20 +5529,44 @@ async function handleApi(req, res) {
|
|
|
5438
5529
|
};
|
|
5439
5530
|
}).sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name));
|
|
5440
5531
|
// Surface any posts that didn't match a weighted project, so the matrix adds up.
|
|
5532
|
+
// configured = the project name is in config.json (just at weight 0), so the
|
|
5533
|
+
// weight editor stays available. unconfigured rows come from stale project
|
|
5534
|
+
// names found in post rows only and aren't editable here.
|
|
5441
5535
|
const knownNames = new Set(weighted.map(p => p.name));
|
|
5536
|
+
const configuredNames = new Set(configuredProjects.map(p => p.name));
|
|
5537
|
+
const configuredByName = Object.fromEntries(configuredProjects.map(p => [p.name, p]));
|
|
5442
5538
|
const unassigned = Object.entries(byProject)
|
|
5443
5539
|
.filter(([name]) => !knownNames.has(name))
|
|
5444
5540
|
.map(([name, stats]) => ({
|
|
5445
5541
|
name,
|
|
5446
|
-
weight: 0,
|
|
5542
|
+
weight: Number((configuredByName[name] || {}).weight) || 0,
|
|
5447
5543
|
target_share: 0,
|
|
5448
5544
|
total: stats.total,
|
|
5449
5545
|
actual_share: grandTotal > 0 ? stats.total / grandTotal : 0,
|
|
5450
5546
|
deficit: -(grandTotal > 0 ? stats.total / grandTotal : 0),
|
|
5451
5547
|
by_platform: Object.fromEntries(platforms.map(pl => [pl, stats.by_platform[pl] || 0])),
|
|
5452
|
-
website: null,
|
|
5548
|
+
website: (configuredByName[name] && configuredByName[name].website) || null,
|
|
5453
5549
|
unassigned: true,
|
|
5550
|
+
configured: configuredNames.has(name),
|
|
5454
5551
|
}));
|
|
5552
|
+
// Also include configured projects with weight=0 and zero posts in the
|
|
5553
|
+
// window, so an operator can lift them back up from the table.
|
|
5554
|
+
configuredProjects.forEach(cp => {
|
|
5555
|
+
if (knownNames.has(cp.name)) return;
|
|
5556
|
+
if (byProject[cp.name]) return;
|
|
5557
|
+
unassigned.push({
|
|
5558
|
+
name: cp.name,
|
|
5559
|
+
weight: Number(cp.weight) || 0,
|
|
5560
|
+
target_share: 0,
|
|
5561
|
+
total: 0,
|
|
5562
|
+
actual_share: 0,
|
|
5563
|
+
deficit: 0,
|
|
5564
|
+
by_platform: Object.fromEntries(platforms.map(pl => [pl, 0])),
|
|
5565
|
+
website: cp.website || null,
|
|
5566
|
+
unassigned: true,
|
|
5567
|
+
configured: true,
|
|
5568
|
+
});
|
|
5569
|
+
});
|
|
5455
5570
|
// Per-project Claude cost in the same window. Mirrors /api/cost/stats
|
|
5456
5571
|
// attribution: per_row_cost = COALESCE(orchestrator_cost_usd,
|
|
5457
5572
|
// total_cost_usd) / rows_in_session, summed across the activity rows
|
|
@@ -5572,11 +5687,44 @@ async function handleApi(req, res) {
|
|
|
5572
5687
|
grand_cost_usd_orchestrator: grandCostOrch,
|
|
5573
5688
|
grand_cost_usd_estimated: grandCostEst,
|
|
5574
5689
|
cost_available: !!(req.user && req.user.admin),
|
|
5690
|
+
can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
|
|
5575
5691
|
projects,
|
|
5576
5692
|
unassigned,
|
|
5577
5693
|
});
|
|
5578
5694
|
}
|
|
5579
5695
|
|
|
5696
|
+
// POST /api/project/weight - update one project's weight in config.json.
|
|
5697
|
+
// Admin only and disabled in CLIENT_MODE (no config.json on Cloud Run).
|
|
5698
|
+
// Body: { name: string, weight: number }. Weight must be a non-negative
|
|
5699
|
+
// finite number. The picker reads config.json directly on each pick, so no
|
|
5700
|
+
// restart or cache bust is needed.
|
|
5701
|
+
if (p === '/api/project/weight' && req.method === 'POST') {
|
|
5702
|
+
if (auth.CLIENT_MODE) return json(res, { error: 'config_readonly_in_client_mode' }, 405);
|
|
5703
|
+
if (!req.user || !req.user.admin) return json(res, { error: 'forbidden' }, 403);
|
|
5704
|
+
return readBody(req).then(body => {
|
|
5705
|
+
let payload;
|
|
5706
|
+
try { payload = JSON.parse(body); }
|
|
5707
|
+
catch { return json(res, { error: 'invalid_json' }, 400); }
|
|
5708
|
+
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
|
5709
|
+
const weight = Number(payload.weight);
|
|
5710
|
+
if (!name) return json(res, { error: 'name_required' }, 400);
|
|
5711
|
+
if (!Number.isFinite(weight) || weight < 0 || weight > 1e6) {
|
|
5712
|
+
return json(res, { error: 'invalid_weight' }, 400);
|
|
5713
|
+
}
|
|
5714
|
+
let config;
|
|
5715
|
+
try { config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
|
|
5716
|
+
catch (e) { return json(res, { error: 'config_read_failed', detail: e.message }, 500); }
|
|
5717
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
5718
|
+
const target = projects.find(pr => pr && pr.name === name);
|
|
5719
|
+
if (!target) return json(res, { error: 'project_not_found', name }, 404);
|
|
5720
|
+
const previous = Number(target.weight) || 0;
|
|
5721
|
+
target.weight = weight;
|
|
5722
|
+
try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n'); }
|
|
5723
|
+
catch (e) { return json(res, { error: 'config_write_failed', detail: e.message }, 500); }
|
|
5724
|
+
return json(res, { saved: true, name, weight, previous });
|
|
5725
|
+
}).catch(e => json(res, { error: e.message }, 400));
|
|
5726
|
+
}
|
|
5727
|
+
|
|
5580
5728
|
// GET /api/deploy/status - latest Vercel production deploy per project.
|
|
5581
5729
|
// Written every ~5 min to skill/cache/deploy_status.json by launchd
|
|
5582
5730
|
// com.m13v.social-deploy-status (scripts/project_deploy_status.py). If the
|
|
@@ -5888,6 +6036,15 @@ const HTML = `<!DOCTYPE html>
|
|
|
5888
6036
|
}
|
|
5889
6037
|
.sa-del-btn { position: relative; }
|
|
5890
6038
|
@keyframes saDelSpin { to { transform: rotate(360deg); } }
|
|
6039
|
+
/* Inline editable weight cell: input + small spinner shown while saving. */
|
|
6040
|
+
.pw-cell { display: inline-flex; align-items: center; gap: 4px; justify-content: flex-end; }
|
|
6041
|
+
.pw-spinner {
|
|
6042
|
+
display: none; width: 10px; height: 10px; border: 1.5px solid transparent;
|
|
6043
|
+
border-top-color: var(--text-muted); border-right-color: var(--text-muted);
|
|
6044
|
+
border-radius: 50%; animation: saDelSpin 0.7s linear infinite;
|
|
6045
|
+
}
|
|
6046
|
+
.pw-cell.is-saving .pw-spinner { display: inline-block; }
|
|
6047
|
+
.pw-cell.is-saving input { opacity: 0.6; }
|
|
5891
6048
|
.sa-del-btn.is-pending {
|
|
5892
6049
|
opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
|
|
5893
6050
|
background: rgba(245, 158, 11, 0.08);
|
|
@@ -7538,6 +7695,12 @@ function renderResult(run) {
|
|
|
7538
7695
|
const salvageableLive = r.salvageable_now || 0;
|
|
7539
7696
|
const salvAdded = r.salvageable_added || 0;
|
|
7540
7697
|
const salvDrained = r.salvageable_drained || 0;
|
|
7698
|
+
// Actual Phase 0 salvage this cycle did (read from cycle log) and the
|
|
7699
|
+
// count of those salvaged rows that ended up posted. Distinct from
|
|
7700
|
+
// salvageable_now, which is the pool size for the NEXT cycle. Mirrors
|
|
7701
|
+
// Reddit's salvage_attempted / salvage_posted split.
|
|
7702
|
+
const salvAttempted = r.salvage_attempted || 0;
|
|
7703
|
+
const salvPosted = r.salvage_posted || 0;
|
|
7541
7704
|
// Legacy queue fields kept for the tooltip (operator can still see queue
|
|
7542
7705
|
// depth + drain breakdown if they hover the pill).
|
|
7543
7706
|
const queue = (r.queue_end != null) ? r.queue_end : (r.pending_queue || 0);
|
|
@@ -7564,22 +7727,24 @@ function renderResult(run) {
|
|
|
7564
7727
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
7565
7728
|
label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
|
|
7566
7729
|
};
|
|
7567
|
-
// Salvaged pill
|
|
7568
|
-
//
|
|
7569
|
-
//
|
|
7570
|
-
//
|
|
7571
|
-
//
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7730
|
+
// Salvaged pill. Primary number is what THIS cycle's Phase 0 actually
|
|
7731
|
+
// salvaged (from the cycle log). Falls back to the future-pool size when
|
|
7732
|
+
// no cycle log was found, so old rows still surface something. Bracket
|
|
7733
|
+
// shows posted-from-salvage when an attempt happened, otherwise +A/-D
|
|
7734
|
+
// pool delta.
|
|
7735
|
+
const salvPrimary = salvAttempted || salvageableLive;
|
|
7736
|
+
let salvBracket = '';
|
|
7737
|
+
if (salvAttempted > 0) {
|
|
7738
|
+
salvBracket = ' <span style="color:var(--muted);font-weight:400;">(' +
|
|
7739
|
+
salvPosted + ' posted)</span>';
|
|
7740
|
+
} else if (salvAdded || salvDrained) {
|
|
7741
|
+
salvBracket = ' <span style="color:var(--muted);font-weight:400;">(' +
|
|
7742
|
+
'+' + salvAdded + '/-' + salvDrained + ' pool)</span>';
|
|
7743
|
+
}
|
|
7579
7744
|
const queuePill =
|
|
7580
7745
|
'<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
7581
|
-
'salvaged <span style="color:var(--text);font-weight:600;">' +
|
|
7582
|
-
|
|
7746
|
+
'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
|
|
7747
|
+
salvBracket +
|
|
7583
7748
|
'</span>';
|
|
7584
7749
|
const tooltip = 'searches: ' + searches +
|
|
7585
7750
|
' / raw tweets: ' + raw +
|
|
@@ -7588,7 +7753,9 @@ function renderResult(run) {
|
|
|
7588
7753
|
' / expired (delta<1 floor): ' + expired +
|
|
7589
7754
|
' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
|
|
7590
7755
|
' / posted: ' + posted +
|
|
7591
|
-
' /
|
|
7756
|
+
' / Phase 0 salvaged into this cycle: ' + salvAttempted +
|
|
7757
|
+
' (of which posted: ' + salvPosted + ')' +
|
|
7758
|
+
' / salvageable now (pool size for next cycle): ' + salvageableLive +
|
|
7592
7759
|
' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
|
|
7593
7760
|
' / pending end-of-run: ' + queue +
|
|
7594
7761
|
' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
|
|
@@ -9768,7 +9935,7 @@ function renderDailyMetrics() {
|
|
|
9768
9935
|
// the same Trends-tab filters because it reuses _dailyMetricsSeries directly,
|
|
9769
9936
|
// no new fetch needed. Values are percentages (0-100), formatted to one
|
|
9770
9937
|
// decimal place; days with views=0 are dropped (ratios are undefined).
|
|
9771
|
-
|
|
9938
|
+
let RATIO_METRICS = [
|
|
9772
9939
|
{ id: 'upvotes_per_view', label: 'Upvotes / Views', color: '#f97316', numerator: 'upvotes', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
9773
9940
|
{ id: 'comments_per_view', label: 'Comments / Views', color: '#14b8a6', numerator: 'comments', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
9774
9941
|
{ id: 'clicks_per_view', label: 'Clicks / Views', color: '#0ea5e9', numerator: 'clicks', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
@@ -9784,8 +9951,8 @@ const RATIO_METRICS = [
|
|
|
9784
9951
|
// format='usd' switches the legend pill, axis labels, bar labels, and
|
|
9785
9952
|
// tooltip to dollar rendering. Days with denominator=0 still drop out
|
|
9786
9953
|
// (NaN) so the chart shows a gap rather than a misleading $0 bar.
|
|
9787
|
-
{ id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000 },
|
|
9788
|
-
{ id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000 },
|
|
9954
|
+
{ id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000, adminOnly: true },
|
|
9955
|
+
{ id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000, adminOnly: true },
|
|
9789
9956
|
];
|
|
9790
9957
|
const RATIO_METRICS_DEFAULTS = ['upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
|
|
9791
9958
|
// .v2: ratio set expanded to include cost_per_kviews + cost_per_kvisitors.
|
|
@@ -13511,8 +13678,67 @@ const PROJECT_STATUS_PLATFORM_LABELS = {
|
|
|
13511
13678
|
moltbook: 'MoltBook', github: 'GitHub',
|
|
13512
13679
|
};
|
|
13513
13680
|
let _projectStatusLoading = false;
|
|
13681
|
+
let _projectStatusData = null;
|
|
13682
|
+
let _projectStatusOrder = null;
|
|
13683
|
+
const PROJECT_STATUS_SORT_STORAGE = 'sa.projectStatus.sort.v1';
|
|
13684
|
+
let _projectStatusSort = { field: 'weight', dir: 'desc' };
|
|
13685
|
+
try {
|
|
13686
|
+
const saved = JSON.parse(localStorage.getItem(PROJECT_STATUS_SORT_STORAGE) || 'null');
|
|
13687
|
+
if (saved && typeof saved.field === 'string' && (saved.dir === 'asc' || saved.dir === 'desc')) {
|
|
13688
|
+
_projectStatusSort = { field: saved.field, dir: saved.dir };
|
|
13689
|
+
}
|
|
13690
|
+
} catch (e) {}
|
|
13691
|
+
function _persistProjectStatusSort() {
|
|
13692
|
+
try { localStorage.setItem(PROJECT_STATUS_SORT_STORAGE, JSON.stringify(_projectStatusSort)); } catch (e) {}
|
|
13693
|
+
}
|
|
13694
|
+
const PROJECT_STATUS_SORT_FIELDS = {
|
|
13695
|
+
name: { type: 'string', value: r => r.name || '' },
|
|
13696
|
+
weight: { type: 'numeric', value: r => Number(r.weight) || 0 },
|
|
13697
|
+
target_share: { type: 'numeric', value: r => Number(r.target_share) || 0 },
|
|
13698
|
+
total: { type: 'numeric', value: r => Number(r.total) || 0 },
|
|
13699
|
+
cost: { type: 'numeric', value: r => Number(r.cost_usd) || 0 },
|
|
13700
|
+
reddit: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.reddit) || 0 },
|
|
13701
|
+
twitter: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.twitter) || 0 },
|
|
13702
|
+
linkedin: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.linkedin) || 0 },
|
|
13703
|
+
moltbook: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.moltbook) || 0 },
|
|
13704
|
+
github: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.github) || 0 },
|
|
13705
|
+
};
|
|
13706
|
+
function _sortProjectRows(rows) {
|
|
13707
|
+
const { field, dir } = _projectStatusSort;
|
|
13708
|
+
const cfg = PROJECT_STATUS_SORT_FIELDS[field] || PROJECT_STATUS_SORT_FIELDS.weight;
|
|
13709
|
+
const mul = dir === 'asc' ? 1 : -1;
|
|
13710
|
+
return rows.slice().sort((a, b) => {
|
|
13711
|
+
// Unassigned rows always live at the bottom regardless of sort.
|
|
13712
|
+
if (!!a.unassigned !== !!b.unassigned) return a.unassigned ? 1 : -1;
|
|
13713
|
+
const va = cfg.value(a); const vb = cfg.value(b);
|
|
13714
|
+
if (cfg.type === 'numeric') {
|
|
13715
|
+
const diff = (Number(va) - Number(vb)) * mul;
|
|
13716
|
+
if (diff !== 0) return diff;
|
|
13717
|
+
return String(a.name || '').localeCompare(String(b.name || ''));
|
|
13718
|
+
}
|
|
13719
|
+
return String(va).localeCompare(String(vb)) * mul;
|
|
13720
|
+
});
|
|
13721
|
+
}
|
|
13722
|
+
function _applyProjectStatusOrder(rows) {
|
|
13723
|
+
if (!_projectStatusOrder) return _sortProjectRows(rows);
|
|
13724
|
+
// Render previously-captured order, appending any new rows at the bottom
|
|
13725
|
+
// (still respecting unassigned-at-bottom). This is what keeps a freshly-
|
|
13726
|
+
// edited row from jumping after a save: order is frozen until the user
|
|
13727
|
+
// explicitly resorts (header click) or refreshes (↻).
|
|
13728
|
+
const byName = new Map(rows.map(r => [r.name, r]));
|
|
13729
|
+
const used = new Set();
|
|
13730
|
+
const out = [];
|
|
13731
|
+
for (const name of _projectStatusOrder) {
|
|
13732
|
+
const r = byName.get(name);
|
|
13733
|
+
if (r) { out.push(r); used.add(name); }
|
|
13734
|
+
}
|
|
13735
|
+
const leftovers = rows.filter(r => !used.has(r.name));
|
|
13736
|
+
// Keep unassigned at the bottom even within the leftovers slice.
|
|
13737
|
+
leftovers.sort((a, b) => (a.unassigned ? 1 : 0) - (b.unassigned ? 1 : 0));
|
|
13738
|
+
return out.concat(leftovers);
|
|
13739
|
+
}
|
|
13514
13740
|
function formatPct(v) { return (Number(v || 0) * 100).toFixed(1) + '%'; }
|
|
13515
|
-
function renderProjectStatus(data) {
|
|
13741
|
+
function renderProjectStatus(data, opts) {
|
|
13516
13742
|
const body = document.getElementById('project-status-body');
|
|
13517
13743
|
const totalEl = document.getElementById('project-status-total');
|
|
13518
13744
|
const heading = document.getElementById('project-status-heading');
|
|
@@ -13522,6 +13748,8 @@ function renderProjectStatus(data) {
|
|
|
13522
13748
|
body.innerHTML = '<div class="style-stats-empty">' + escapeHtml(data.error) + '</div>';
|
|
13523
13749
|
return;
|
|
13524
13750
|
}
|
|
13751
|
+
_projectStatusData = data;
|
|
13752
|
+
const preserveOrder = !!(opts && opts.preserveOrder);
|
|
13525
13753
|
const hours = Number(data && data.hours) || 24;
|
|
13526
13754
|
if (heading) heading.textContent = 'Project Status (last ' + hours + 'h)';
|
|
13527
13755
|
const projects = (data && data.projects) || [];
|
|
@@ -13529,6 +13757,7 @@ function renderProjectStatus(data) {
|
|
|
13529
13757
|
const grandTotal = Number(data && data.grand_total) || 0;
|
|
13530
13758
|
const totals = (data && data.platform_totals) || {};
|
|
13531
13759
|
const costAvailable = !!(data && data.cost_available);
|
|
13760
|
+
const canEditWeight = !!(data && data.can_edit_weight);
|
|
13532
13761
|
const grandCost = Number(data && data.grand_cost_usd) || 0;
|
|
13533
13762
|
const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
|
|
13534
13763
|
const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
|
|
@@ -13580,16 +13809,27 @@ function renderProjectStatus(data) {
|
|
|
13580
13809
|
body.innerHTML = '<div class="style-stats-empty">No projects configured with weight > 0.</div>';
|
|
13581
13810
|
return;
|
|
13582
13811
|
}
|
|
13812
|
+
const sortHeader = (key, label, align) => {
|
|
13813
|
+
const alignStyle = align === 'left' ? 'text-align:left;' : 'text-align:right;';
|
|
13814
|
+
const active = _projectStatusSort.field === key;
|
|
13815
|
+
const arrow = active ? (_projectStatusSort.dir === 'asc' ? '▲' : '▼') : '';
|
|
13816
|
+
const arrowCls = 'activity-sort-arrow' + (active ? ' active' : '');
|
|
13817
|
+
return '<th class="activity-sortable" data-project-sort-key="' + key + '" style="' + alignStyle + '">' +
|
|
13818
|
+
'<span class="activity-header-label">' + label +
|
|
13819
|
+
' <span class="' + arrowCls + '" data-project-sort-arrow="' + key + '">' + arrow + '</span>' +
|
|
13820
|
+
'</span>' +
|
|
13821
|
+
'</th>';
|
|
13822
|
+
};
|
|
13583
13823
|
const header =
|
|
13584
13824
|
'<thead><tr>' +
|
|
13585
|
-
'
|
|
13586
|
-
'
|
|
13587
|
-
'
|
|
13825
|
+
sortHeader('name', 'Project', 'left') +
|
|
13826
|
+
sortHeader('weight', 'Weight') +
|
|
13827
|
+
sortHeader('target_share', 'Target %') +
|
|
13588
13828
|
PROJECT_STATUS_PLATFORMS.map(p =>
|
|
13589
|
-
|
|
13829
|
+
sortHeader(p, PROJECT_STATUS_PLATFORM_LABELS[p])
|
|
13590
13830
|
).join('') +
|
|
13591
|
-
'
|
|
13592
|
-
(costAvailable ? '
|
|
13831
|
+
sortHeader('total', 'Total') +
|
|
13832
|
+
(costAvailable ? sortHeader('cost', 'Cost') : '') +
|
|
13593
13833
|
'</tr></thead>';
|
|
13594
13834
|
const cellWithShare = (n, platformTotal, targetShare, opts) => {
|
|
13595
13835
|
const num = Number(n) || 0;
|
|
@@ -13635,16 +13875,36 @@ function renderProjectStatus(data) {
|
|
|
13635
13875
|
const costCellHtml = costAvailable
|
|
13636
13876
|
? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
|
|
13637
13877
|
: '';
|
|
13878
|
+
const weightVal = Number(r.weight) || 0;
|
|
13879
|
+
const editable = canEditWeight && (!r.unassigned || r.configured);
|
|
13880
|
+
const weightCellHtml = editable
|
|
13881
|
+
? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
|
|
13882
|
+
'<span class="pw-cell" data-project-weight-cell="' + escapeHtml(r.name) + '">' +
|
|
13883
|
+
'<input type="number" min="0" step="1" value="' + weightVal + '" ' +
|
|
13884
|
+
'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
|
|
13885
|
+
'data-original-weight="' + weightVal + '" ' +
|
|
13886
|
+
'class="project-weight-input" ' +
|
|
13887
|
+
'style="width:56px;text-align:right;font-variant-numeric:tabular-nums;padding:2px 6px;border:1px solid var(--border);background:var(--bg-subtle,transparent);color:var(--text);border-radius:4px;font-size:13px;font-weight:600;" ' +
|
|
13888
|
+
'title="Edit and press Enter or blur to save" />' +
|
|
13889
|
+
'<span class="pw-spinner" aria-hidden="true"></span>' +
|
|
13890
|
+
'</span>' +
|
|
13891
|
+
'</td>'
|
|
13892
|
+
: '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
|
|
13638
13893
|
return '<tr>' +
|
|
13639
13894
|
'<td style="text-align:left;font-weight:600;">' + nameLabel + '</td>' +
|
|
13640
|
-
|
|
13895
|
+
weightCellHtml +
|
|
13641
13896
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + (r.unassigned ? '—' : formatPct(r.target_share)) + '</td>' +
|
|
13642
13897
|
platformCells +
|
|
13643
13898
|
totalCell +
|
|
13644
13899
|
costCellHtml +
|
|
13645
13900
|
'</tr>';
|
|
13646
13901
|
};
|
|
13647
|
-
const
|
|
13902
|
+
const allRows = projects.concat(unassigned);
|
|
13903
|
+
const ordered = preserveOrder ? _applyProjectStatusOrder(allRows) : _sortProjectRows(allRows);
|
|
13904
|
+
// Capture the order we just rendered so future in-place saves (or
|
|
13905
|
+
// background reloads) don't reshuffle rows under the operator.
|
|
13906
|
+
_projectStatusOrder = ordered.map(r => r.name);
|
|
13907
|
+
const bodyRows = ordered.map(rowHtml).join('');
|
|
13648
13908
|
const footerCells = PROJECT_STATUS_PLATFORMS.map(p =>
|
|
13649
13909
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
|
|
13650
13910
|
).join('');
|
|
@@ -13668,6 +13928,86 @@ function renderProjectStatus(data) {
|
|
|
13668
13928
|
'<tbody>' + bodyRows + footerHtml + '</tbody>' +
|
|
13669
13929
|
'</table>' +
|
|
13670
13930
|
'</div>' + legend;
|
|
13931
|
+
body.querySelectorAll('[data-project-sort-key]').forEach(th => {
|
|
13932
|
+
th.addEventListener('click', () => {
|
|
13933
|
+
const key = th.getAttribute('data-project-sort-key');
|
|
13934
|
+
if (!PROJECT_STATUS_SORT_FIELDS[key]) return;
|
|
13935
|
+
const cfg = PROJECT_STATUS_SORT_FIELDS[key];
|
|
13936
|
+
const defaultDir = cfg.type === 'numeric' ? 'desc' : 'asc';
|
|
13937
|
+
if (_projectStatusSort.field === key) {
|
|
13938
|
+
_projectStatusSort.dir = _projectStatusSort.dir === 'asc' ? 'desc' : 'asc';
|
|
13939
|
+
} else {
|
|
13940
|
+
_projectStatusSort.field = key;
|
|
13941
|
+
_projectStatusSort.dir = defaultDir;
|
|
13942
|
+
}
|
|
13943
|
+
_persistProjectStatusSort();
|
|
13944
|
+
// Header click is an explicit user-initiated re-sort, so drop the
|
|
13945
|
+
// sticky order and let _sortProjectRows recompute.
|
|
13946
|
+
_projectStatusOrder = null;
|
|
13947
|
+
if (_projectStatusData) renderProjectStatus(_projectStatusData);
|
|
13948
|
+
});
|
|
13949
|
+
});
|
|
13950
|
+
if (canEditWeight) {
|
|
13951
|
+
body.querySelectorAll('input.project-weight-input').forEach(inp => {
|
|
13952
|
+
inp.addEventListener('keydown', e => {
|
|
13953
|
+
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
|
13954
|
+
else if (e.key === 'Escape') {
|
|
13955
|
+
inp.value = inp.dataset.originalWeight || '0';
|
|
13956
|
+
inp.blur();
|
|
13957
|
+
}
|
|
13958
|
+
});
|
|
13959
|
+
inp.addEventListener('blur', () => saveProjectWeight(inp));
|
|
13960
|
+
});
|
|
13961
|
+
}
|
|
13962
|
+
}
|
|
13963
|
+
async function saveProjectWeight(inp) {
|
|
13964
|
+
const name = inp.dataset.projectWeightInput;
|
|
13965
|
+
const original = Number(inp.dataset.originalWeight) || 0;
|
|
13966
|
+
const raw = inp.value.trim();
|
|
13967
|
+
const next = Number(raw);
|
|
13968
|
+
if (!name) return;
|
|
13969
|
+
if (raw === '' || !Number.isFinite(next) || next < 0) {
|
|
13970
|
+
inp.value = String(original);
|
|
13971
|
+
return;
|
|
13972
|
+
}
|
|
13973
|
+
if (next === original) return;
|
|
13974
|
+
const cell = inp.closest('.pw-cell');
|
|
13975
|
+
if (cell) cell.classList.add('is-saving');
|
|
13976
|
+
inp.disabled = true;
|
|
13977
|
+
const prevBorder = inp.style.borderColor;
|
|
13978
|
+
inp.style.borderColor = 'var(--text-muted)';
|
|
13979
|
+
try {
|
|
13980
|
+
const res = await fetch('/api/project/weight', {
|
|
13981
|
+
method: 'POST',
|
|
13982
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13983
|
+
body: JSON.stringify({ name, weight: next }),
|
|
13984
|
+
});
|
|
13985
|
+
const data = await res.json().catch(() => ({}));
|
|
13986
|
+
if (!res.ok || data.error) {
|
|
13987
|
+
inp.value = String(original);
|
|
13988
|
+
inp.style.borderColor = '#b91c1c';
|
|
13989
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
|
|
13990
|
+
console.error('[project-weight] save failed', data);
|
|
13991
|
+
return;
|
|
13992
|
+
}
|
|
13993
|
+
inp.dataset.originalWeight = String(next);
|
|
13994
|
+
inp.style.borderColor = '#15803d';
|
|
13995
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
|
|
13996
|
+
try { window.posthog && window.posthog.capture('project_weight_edit', { project: name, weight: next, previous: original }); } catch (er) {}
|
|
13997
|
+
// Pull fresh totals + target % from the server, but preserve the row
|
|
13998
|
+
// order so the just-edited row stays where the operator saw it. The
|
|
13999
|
+
// order will be refreshed on header click or the ↻ refresh button.
|
|
14000
|
+
_projectStatusLoading = false;
|
|
14001
|
+
loadProjectStatus(true, { preserveOrder: true });
|
|
14002
|
+
} catch (e) {
|
|
14003
|
+
inp.value = String(original);
|
|
14004
|
+
inp.style.borderColor = '#b91c1c';
|
|
14005
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
|
|
14006
|
+
console.error('[project-weight] save error', e);
|
|
14007
|
+
} finally {
|
|
14008
|
+
inp.disabled = false;
|
|
14009
|
+
if (cell) cell.classList.remove('is-saving');
|
|
14010
|
+
}
|
|
13671
14011
|
}
|
|
13672
14012
|
async function refreshAllData() {
|
|
13673
14013
|
const icon = document.getElementById('global-refresh-icon');
|
|
@@ -13694,7 +14034,7 @@ async function refreshAllData() {
|
|
|
13694
14034
|
loadTopDms(true);
|
|
13695
14035
|
loadActivity();
|
|
13696
14036
|
}
|
|
13697
|
-
async function loadProjectStatus(force) {
|
|
14037
|
+
async function loadProjectStatus(force, opts) {
|
|
13698
14038
|
if (_projectStatusLoading) return;
|
|
13699
14039
|
if (saAuthNotReady()) return;
|
|
13700
14040
|
_projectStatusLoading = true;
|
|
@@ -13702,7 +14042,7 @@ async function loadProjectStatus(force) {
|
|
|
13702
14042
|
const hours = currentStatusWindow().hours;
|
|
13703
14043
|
const res = await fetch('/api/project/status?hours=' + hours);
|
|
13704
14044
|
const data = await res.json();
|
|
13705
|
-
renderProjectStatus(data);
|
|
14045
|
+
renderProjectStatus(data, opts);
|
|
13706
14046
|
} catch (e) {
|
|
13707
14047
|
renderProjectStatus({ error: String(e && e.message || e) });
|
|
13708
14048
|
} finally {
|
|
@@ -14208,7 +14548,10 @@ function saStartApp() {
|
|
|
14208
14548
|
document.body.classList.remove('sa-authed-pending');
|
|
14209
14549
|
const isCloud = document.body.classList.contains('sa-cloud');
|
|
14210
14550
|
const isAdmin = window.SA_IS_ADMIN !== false;
|
|
14211
|
-
if (!isAdmin)
|
|
14551
|
+
if (!isAdmin) {
|
|
14552
|
+
DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
|
|
14553
|
+
RATIO_METRICS = RATIO_METRICS.filter(m => !m.adminOnly);
|
|
14554
|
+
}
|
|
14212
14555
|
try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
|
|
14213
14556
|
// Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
|
|
14214
14557
|
// are admin-only too, so skipping them on cloud also stops 403 spam for
|
|
@@ -14228,7 +14571,10 @@ function saStartApp() {
|
|
|
14228
14571
|
loadDeployHealth();
|
|
14229
14572
|
setInterval(loadDeployHealth, 60000);
|
|
14230
14573
|
loadProjectStatus();
|
|
14231
|
-
|
|
14574
|
+
// Silent background polls preserve the current row order so editing one
|
|
14575
|
+
// weight + waiting 60s doesn't shuffle rows under the operator. Explicit
|
|
14576
|
+
// ↻ refresh and column-header clicks re-sort.
|
|
14577
|
+
setInterval(() => loadProjectStatus(false, { preserveOrder: true }), 60000);
|
|
14232
14578
|
}
|
|
14233
14579
|
// Funnel + DM stats sections are \`<details open>\` by default; load them
|
|
14234
14580
|
// here (post-auth) rather than in their wire IIFEs, which fire before
|
package/package.json
CHANGED