social-autoposter 1.3.5 → 1.3.7
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 +394 -46
- package/package.json +3 -9
- package/schema-postgres.sql +8 -0
- package/scripts/daily_stats_email.py +502 -171
- package/scripts/dm_conversation.py +2 -1
- package/scripts/dm_short_links.py +153 -49
- package/scripts/engage_reddit.py +14 -3
- package/scripts/linkedin_api.py +10 -3
- 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/send_dashboard_invite.py +141 -0
- package/scripts/twitter_browser.py +109 -55
- package/scripts/twitter_post_plan.py +10 -3
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +55 -0
- package/skill/audit-linkedin.sh +4 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +65 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +228 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +206 -0
- package/skill/dm-outreach-linkedin.sh +237 -0
- package/skill/dm-outreach-reddit.sh +276 -0
- package/skill/dm-outreach-twitter.sh +296 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1682 -0
- package/skill/engage-linkedin.sh +475 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +123 -0
- package/skill/engage-twitter.sh +489 -0
- package/skill/github-engage.sh +172 -0
- package/skill/ingest-web-chat-replies.sh +40 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/twitter-backend.sh +165 -0
- package/skill/link-edit-github.sh +126 -0
- package/skill/link-edit-linkedin.sh +127 -0
- package/skill/link-edit-moltbook.sh +128 -0
- package/skill/link-edit-reddit.sh +205 -0
- package/skill/lock.sh +516 -0
- package/skill/octolens-linkedin.sh +4 -0
- package/skill/octolens-reddit.sh +4 -0
- package/skill/octolens-twitter.sh +4 -0
- package/skill/octolens.sh +163 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/promote-engagement-styles.sh +45 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-instagram-daily.sh +94 -0
- package/skill/run-instagram-render.sh +519 -0
- package/skill/run-linkedin-launchd.sh +70 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-threads.sh +602 -0
- package/skill/scan-twitter-followups.sh +52 -0
- package/skill/stats-linkedin.sh +189 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +10 -0
- package/skill/sweep-link-clicks.sh +40 -0
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 : [],
|
|
@@ -4637,7 +4728,7 @@ async function handleApi(req, res) {
|
|
|
4637
4728
|
"COALESCE(tlm.last_at, d.last_message_at) AS last_message_at, " +
|
|
4638
4729
|
"d.discovered_at, " +
|
|
4639
4730
|
"d.conversation_status, d.interest_level, d.mode, " +
|
|
4640
|
-
"d.human_reason, d.flagged_at, " +
|
|
4731
|
+
"d.human_reason, d.flagged_at, d.snoozed_until, " +
|
|
4641
4732
|
"d.target_project, d.icp_precheck, d.icp_matches, d.qualification_status, " +
|
|
4642
4733
|
"d.qualification_notes, d.booking_link_sent_at, " +
|
|
4643
4734
|
// dm_links aggregates replace the legacy single-link columns. Latest
|
|
@@ -4754,7 +4845,8 @@ async function handleApi(req, res) {
|
|
|
4754
4845
|
"WHERE mm.dm_id = d.id), " +
|
|
4755
4846
|
"'[]'::json" +
|
|
4756
4847
|
") AS campaign_names, " +
|
|
4757
|
-
"CASE WHEN d.conversation_status = 'needs_human' THEN 0 " +
|
|
4848
|
+
"CASE WHEN d.conversation_status = 'needs_human' AND (d.snoozed_until IS NULL OR d.snoozed_until <= NOW()) THEN 0 " +
|
|
4849
|
+
"WHEN d.conversation_status = 'needs_human' THEN 75 " +
|
|
4758
4850
|
"WHEN d.conversation_status IN ('converted','closed') THEN 90 " +
|
|
4759
4851
|
"WHEN d.interest_level = 'hot' THEN 10 " +
|
|
4760
4852
|
"WHEN d.interest_level = 'warm' THEN 20 " +
|
|
@@ -4937,6 +5029,54 @@ async function handleApi(req, res) {
|
|
|
4937
5029
|
}).catch(e => json(res, { error: e.message }, 500));
|
|
4938
5030
|
}
|
|
4939
5031
|
|
|
5032
|
+
// POST /api/dm/:id/snooze - skip a flagged DM until the prospect sends a new
|
|
5033
|
+
// inbound. Sets dms.snoozed_until to NOW()+30d (cap) so the engage loop and
|
|
5034
|
+
// dashboard escalation card both hide it. Auto-cleared in
|
|
5035
|
+
// scripts/dm_conversation.py log_inbound() the next time a real inbound
|
|
5036
|
+
// message lands, which re-arms the thread under its existing conversation_status.
|
|
5037
|
+
// Body: { hours?: number, unsnooze?: boolean }. hours capped to 720 (30d).
|
|
5038
|
+
const snoozeMatch = p.match(/^\/api\/dm\/(\d+)\/snooze$/);
|
|
5039
|
+
if (snoozeMatch && req.method === 'POST') {
|
|
5040
|
+
const dmId = parseInt(snoozeMatch[1], 10);
|
|
5041
|
+
return readBody(req).then(async (body) => {
|
|
5042
|
+
let payload = {};
|
|
5043
|
+
if (body) { try { payload = JSON.parse(body); } catch { return json(res, { error: 'invalid_json' }, 400); } }
|
|
5044
|
+
const unsnooze = !!(payload && payload.unsnooze);
|
|
5045
|
+
const dmRows = await pq(
|
|
5046
|
+
"SELECT d.id, d.platform, d.their_author, " +
|
|
5047
|
+
"COALESCE(p_direct.project_name, p_via_reply.project_name, d.target_project) AS project_name " +
|
|
5048
|
+
"FROM dms d " +
|
|
5049
|
+
"LEFT JOIN posts p_direct ON p_direct.id = d.post_id " +
|
|
5050
|
+
"LEFT JOIN replies r_link ON r_link.id = d.reply_id " +
|
|
5051
|
+
"LEFT JOIN posts p_via_reply ON p_via_reply.id = r_link.post_id " +
|
|
5052
|
+
"WHERE d.id = $1",
|
|
5053
|
+
[dmId]
|
|
5054
|
+
);
|
|
5055
|
+
if (!dmRows || !dmRows.length) return json(res, { error: 'dm_not_found' }, 404);
|
|
5056
|
+
const dm = dmRows[0];
|
|
5057
|
+
if (!req.user || !req.user.admin) {
|
|
5058
|
+
const projName = dm.project_name || '';
|
|
5059
|
+
const claims = (req.user && Array.isArray(req.user.projects)) ? req.user.projects : [];
|
|
5060
|
+
if (!projName || !claims.includes(projName)) {
|
|
5061
|
+
return json(res, { error: 'forbidden' }, 403);
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
let upd;
|
|
5065
|
+
if (unsnooze) {
|
|
5066
|
+
upd = await pq("UPDATE dms SET snoozed_until = NULL WHERE id = $1 RETURNING id, snoozed_until", [dmId]);
|
|
5067
|
+
} else {
|
|
5068
|
+
const rawHours = Number(payload && payload.hours);
|
|
5069
|
+
const hours = Number.isFinite(rawHours) && rawHours > 0 ? Math.min(720, Math.floor(rawHours)) : 720;
|
|
5070
|
+
upd = await pq(
|
|
5071
|
+
"UPDATE dms SET snoozed_until = NOW() + ($2 || ' hours')::interval WHERE id = $1 RETURNING id, snoozed_until",
|
|
5072
|
+
[dmId, String(hours)]
|
|
5073
|
+
);
|
|
5074
|
+
}
|
|
5075
|
+
if (!upd || !upd.length) return json(res, { error: 'update_failed' }, 500);
|
|
5076
|
+
return json(res, { ok: true, dm_id: dmId, snoozed_until: upd[0].snoozed_until }, 200);
|
|
5077
|
+
}).catch(e => json(res, { error: e.message }, 500));
|
|
5078
|
+
}
|
|
5079
|
+
|
|
4940
5080
|
// GET /api/top - top-performing posts by engagement
|
|
4941
5081
|
// Mirrors scripts/top_performers.py: active posts, non-trivial content,
|
|
4942
5082
|
// excludes platforms we don't score. Default ranking is upvotes DESC (that's
|
|
@@ -5221,7 +5361,7 @@ async function handleApi(req, res) {
|
|
|
5221
5361
|
"COUNT(*) FILTER (WHERE d.qualification_status = 'disqualified')::int AS q_disqualified, " +
|
|
5222
5362
|
"COUNT(*) FILTER (WHERE d.booking_link_sent_at IS NOT NULL)::int AS booking_sent, " +
|
|
5223
5363
|
"COUNT(*) FILTER (WHERE d.conversation_status = 'converted')::int AS converted, " +
|
|
5224
|
-
"COUNT(*) FILTER (WHERE d.conversation_status = 'needs_human')::int AS needs_human " +
|
|
5364
|
+
"COUNT(*) FILTER (WHERE d.conversation_status = 'needs_human' AND (d.snoozed_until IS NULL OR d.snoozed_until <= NOW()))::int AS needs_human " +
|
|
5225
5365
|
"FROM dms d " +
|
|
5226
5366
|
"LEFT JOIN posts p_direct ON p_direct.id = d.post_id " +
|
|
5227
5367
|
"LEFT JOIN replies r_link ON r_link.id = d.reply_id " +
|
|
@@ -5945,6 +6085,15 @@ const HTML = `<!DOCTYPE html>
|
|
|
5945
6085
|
}
|
|
5946
6086
|
.sa-del-btn { position: relative; }
|
|
5947
6087
|
@keyframes saDelSpin { to { transform: rotate(360deg); } }
|
|
6088
|
+
/* Inline editable weight cell: input + small spinner shown while saving. */
|
|
6089
|
+
.pw-cell { display: inline-flex; align-items: center; gap: 4px; justify-content: flex-end; }
|
|
6090
|
+
.pw-spinner {
|
|
6091
|
+
display: none; width: 10px; height: 10px; border: 1.5px solid transparent;
|
|
6092
|
+
border-top-color: var(--text-muted); border-right-color: var(--text-muted);
|
|
6093
|
+
border-radius: 50%; animation: saDelSpin 0.7s linear infinite;
|
|
6094
|
+
}
|
|
6095
|
+
.pw-cell.is-saving .pw-spinner { display: inline-block; }
|
|
6096
|
+
.pw-cell.is-saving input { opacity: 0.6; }
|
|
5948
6097
|
.sa-del-btn.is-pending {
|
|
5949
6098
|
opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
|
|
5950
6099
|
background: rgba(245, 158, 11, 0.08);
|
|
@@ -6329,6 +6478,10 @@ const HTML = `<!DOCTYPE html>
|
|
|
6329
6478
|
.dm-esc-link { margin-left: auto; padding: 2px 8px; font-size: 11px; font-weight: 600; color: #92400e; background: #fef3c7; border: 1px solid #fde68a; border-radius: 4px; text-decoration: none; }
|
|
6330
6479
|
.dm-esc-link:hover { background: #fde68a; }
|
|
6331
6480
|
.dm-esc-link-missing { font-size: 10px; color: var(--text-muted); font-style: italic; }
|
|
6481
|
+
.dm-esc-skip { padding: 2px 8px; font-size: 11px; font-weight: 600; color: var(--text-secondary); background: transparent; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-family: inherit; }
|
|
6482
|
+
.dm-esc-skip:hover { color: var(--text-strong); border-color: var(--border-hover); }
|
|
6483
|
+
.dm-esc-skip:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
6484
|
+
.dm-esc-snoozed { padding: 2px 8px; font-size: 10px; font-weight: 600; color: #1d4ed8; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
6332
6485
|
|
|
6333
6486
|
.prospect-modal-overlay { position: fixed; inset: 0; background: var(--shadow-modal); display: flex; align-items: flex-start; justify-content: center; z-index: 9999; padding: 60px 20px 20px; overflow-y: auto; }
|
|
6334
6487
|
.prospect-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; max-width: 640px; width: 100%; padding: 24px 28px; color: var(--text); font-size: 13px; line-height: 1.5; }
|
|
@@ -7595,6 +7748,12 @@ function renderResult(run) {
|
|
|
7595
7748
|
const salvageableLive = r.salvageable_now || 0;
|
|
7596
7749
|
const salvAdded = r.salvageable_added || 0;
|
|
7597
7750
|
const salvDrained = r.salvageable_drained || 0;
|
|
7751
|
+
// Actual Phase 0 salvage this cycle did (read from cycle log) and the
|
|
7752
|
+
// count of those salvaged rows that ended up posted. Distinct from
|
|
7753
|
+
// salvageable_now, which is the pool size for the NEXT cycle. Mirrors
|
|
7754
|
+
// Reddit's salvage_attempted / salvage_posted split.
|
|
7755
|
+
const salvAttempted = r.salvage_attempted || 0;
|
|
7756
|
+
const salvPosted = r.salvage_posted || 0;
|
|
7598
7757
|
// Legacy queue fields kept for the tooltip (operator can still see queue
|
|
7599
7758
|
// depth + drain breakdown if they hover the pill).
|
|
7600
7759
|
const queue = (r.queue_end != null) ? r.queue_end : (r.pending_queue || 0);
|
|
@@ -7621,22 +7780,24 @@ function renderResult(run) {
|
|
|
7621
7780
|
'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
7622
7781
|
label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
|
|
7623
7782
|
};
|
|
7624
|
-
// Salvaged pill
|
|
7625
|
-
//
|
|
7626
|
-
//
|
|
7627
|
-
//
|
|
7628
|
-
//
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7783
|
+
// Salvaged pill. Primary number is what THIS cycle's Phase 0 actually
|
|
7784
|
+
// salvaged (from the cycle log). Falls back to the future-pool size when
|
|
7785
|
+
// no cycle log was found, so old rows still surface something. Bracket
|
|
7786
|
+
// shows posted-from-salvage when an attempt happened, otherwise +A/-D
|
|
7787
|
+
// pool delta.
|
|
7788
|
+
const salvPrimary = salvAttempted || salvageableLive;
|
|
7789
|
+
let salvBracket = '';
|
|
7790
|
+
if (salvAttempted > 0) {
|
|
7791
|
+
salvBracket = ' <span style="color:var(--muted);font-weight:400;">(' +
|
|
7792
|
+
salvPosted + ' posted)</span>';
|
|
7793
|
+
} else if (salvAdded || salvDrained) {
|
|
7794
|
+
salvBracket = ' <span style="color:var(--muted);font-weight:400;">(' +
|
|
7795
|
+
'+' + salvAdded + '/-' + salvDrained + ' pool)</span>';
|
|
7796
|
+
}
|
|
7636
7797
|
const queuePill =
|
|
7637
7798
|
'<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
|
|
7638
|
-
'salvaged <span style="color:var(--text);font-weight:600;">' +
|
|
7639
|
-
|
|
7799
|
+
'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
|
|
7800
|
+
salvBracket +
|
|
7640
7801
|
'</span>';
|
|
7641
7802
|
const tooltip = 'searches: ' + searches +
|
|
7642
7803
|
' / raw tweets: ' + raw +
|
|
@@ -7645,7 +7806,9 @@ function renderResult(run) {
|
|
|
7645
7806
|
' / expired (delta<1 floor): ' + expired +
|
|
7646
7807
|
' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
|
|
7647
7808
|
' / posted: ' + posted +
|
|
7648
|
-
' /
|
|
7809
|
+
' / Phase 0 salvaged into this cycle: ' + salvAttempted +
|
|
7810
|
+
' (of which posted: ' + salvPosted + ')' +
|
|
7811
|
+
' / salvageable now (pool size for next cycle): ' + salvageableLive +
|
|
7649
7812
|
' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
|
|
7650
7813
|
' / pending end-of-run: ' + queue +
|
|
7651
7814
|
' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
|
|
@@ -9825,7 +9988,7 @@ function renderDailyMetrics() {
|
|
|
9825
9988
|
// the same Trends-tab filters because it reuses _dailyMetricsSeries directly,
|
|
9826
9989
|
// no new fetch needed. Values are percentages (0-100), formatted to one
|
|
9827
9990
|
// decimal place; days with views=0 are dropped (ratios are undefined).
|
|
9828
|
-
|
|
9991
|
+
let RATIO_METRICS = [
|
|
9829
9992
|
{ id: 'upvotes_per_view', label: 'Upvotes / Views', color: '#f97316', numerator: 'upvotes', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
9830
9993
|
{ id: 'comments_per_view', label: 'Comments / Views', color: '#14b8a6', numerator: 'comments', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
9831
9994
|
{ id: 'clicks_per_view', label: 'Clicks / Views', color: '#0ea5e9', numerator: 'clicks', denominator: 'views', format: 'pct', scaleFactor: 100 },
|
|
@@ -9841,8 +10004,8 @@ const RATIO_METRICS = [
|
|
|
9841
10004
|
// format='usd' switches the legend pill, axis labels, bar labels, and
|
|
9842
10005
|
// tooltip to dollar rendering. Days with denominator=0 still drop out
|
|
9843
10006
|
// (NaN) so the chart shows a gap rather than a misleading $0 bar.
|
|
9844
|
-
{ id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000 },
|
|
9845
|
-
{ id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000 },
|
|
10007
|
+
{ id: 'cost_per_kviews', label: 'Cost / 1k Views', color: '#dc2626', numerator: 'cost', denominator: 'views', format: 'usd', scaleFactor: 1000, adminOnly: true },
|
|
10008
|
+
{ id: 'cost_per_kvisitors', label: 'Cost / 1k Visitors', color: '#7c3aed', numerator: 'cost', denominator: 'pageviews', format: 'usd', scaleFactor: 1000, adminOnly: true },
|
|
9846
10009
|
];
|
|
9847
10010
|
const RATIO_METRICS_DEFAULTS = ['upvotes_per_view', 'comments_per_view', 'clicks_per_view', 'email_signups_per_session', 'schedule_clicks_per_session', 'get_started_per_session', 'cost_per_kviews', 'cost_per_kvisitors'];
|
|
9848
10011
|
// .v2: ratio set expanded to include cost_per_kviews + cost_per_kvisitors.
|
|
@@ -12810,6 +12973,7 @@ function renderTopDms(payload) {
|
|
|
12810
12973
|
interest_level: d.interest_level || '',
|
|
12811
12974
|
mode: d.mode || 'rapport',
|
|
12812
12975
|
human_reason: d.human_reason || '',
|
|
12976
|
+
snoozed_until: d.snoozed_until || null,
|
|
12813
12977
|
project_name: d.project_name || '',
|
|
12814
12978
|
target_project: d.target_project || '',
|
|
12815
12979
|
project_display: d.target_project || d.project_name || '',
|
|
@@ -13218,10 +13382,23 @@ function renderDmEscalationCard(dm) {
|
|
|
13218
13382
|
'<a class="dm-esc-link" href="' + escapeHtml(profileUrl) + '" target="_blank" rel="noopener">open profile</a>';
|
|
13219
13383
|
}
|
|
13220
13384
|
}
|
|
13385
|
+
const snoozedTs = dm.snoozed_until ? parseServerUtcTs(dm.snoozed_until) : null;
|
|
13386
|
+
const isSnoozed = !!(snoozedTs && snoozedTs.getTime() > Date.now());
|
|
13387
|
+
const snoozeBtnId = 'dm-esc-skip-' + Number(dm.id);
|
|
13388
|
+
const snoozeLabel = isSnoozed ? 'Unskip' : 'Skip until they reply';
|
|
13389
|
+
const snoozeTitle = isSnoozed
|
|
13390
|
+
? 'Stop ignoring this thread; re-show in human queue.'
|
|
13391
|
+
: 'Hide this thread from the engage loop and the dashboard escalation surface. If they send a new inbound message, it auto-re-arms.';
|
|
13392
|
+
const snoozedBadge = isSnoozed
|
|
13393
|
+
? '<span class="dm-esc-snoozed" title="Auto-cleared when they send a new inbound.">skipped</span>'
|
|
13394
|
+
: '';
|
|
13395
|
+
const skipBtn = '<button type="button" class="dm-esc-skip" id="' + snoozeBtnId + '" title="' + escapeHtml(snoozeTitle) + '" onclick="toggleDmSnooze(this, ' + Number(dm.id) + ', ' + (isSnoozed ? 'true' : 'false') + ')">' + escapeHtml(snoozeLabel) + '</button>';
|
|
13221
13396
|
const head =
|
|
13222
13397
|
'<div class="dm-esc-head">' +
|
|
13223
13398
|
'<span class="dm-esc-tag">escalation</span>' +
|
|
13224
13399
|
(dm.flagged_at ? '<span class="dm-exp-ctx-author">flagged ' + escapeHtml(relTime(dm.flagged_at)) + '</span>' : '') +
|
|
13400
|
+
snoozedBadge +
|
|
13401
|
+
skipBtn +
|
|
13225
13402
|
linkHtml +
|
|
13226
13403
|
'</div>';
|
|
13227
13404
|
|
|
@@ -13416,6 +13593,66 @@ async function submitDmInstructions(btn, dmId) {
|
|
|
13416
13593
|
}
|
|
13417
13594
|
}
|
|
13418
13595
|
|
|
13596
|
+
// Toggle the snoozed_until flag on a flagged DM. POSTs to /api/dm/:id/snooze
|
|
13597
|
+
// with {unsnooze:true} (to clear) or {} (to set NOW()+30d). After the response
|
|
13598
|
+
// lands we update the in-memory dm.snoozed_until and re-render the card so the
|
|
13599
|
+
// badge and button label flip without a full reload. The next time the
|
|
13600
|
+
// prospect sends an inbound, dm_conversation.log_inbound clears snoozed_until
|
|
13601
|
+
// automatically and the thread re-surfaces in the engage queue.
|
|
13602
|
+
async function toggleDmSnooze(btn, dmId, currentlySnoozed) {
|
|
13603
|
+
if (!btn) return;
|
|
13604
|
+
btn.disabled = true;
|
|
13605
|
+
const prevText = btn.textContent;
|
|
13606
|
+
btn.textContent = currentlySnoozed ? 'Unskipping…' : 'Skipping…';
|
|
13607
|
+
try {
|
|
13608
|
+
const resp = await fetch('/api/dm/' + dmId + '/snooze', {
|
|
13609
|
+
method: 'POST',
|
|
13610
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13611
|
+
body: JSON.stringify(currentlySnoozed ? { unsnooze: true } : {}),
|
|
13612
|
+
});
|
|
13613
|
+
let data = {};
|
|
13614
|
+
try { data = await resp.json(); } catch (_) {}
|
|
13615
|
+
if (!resp.ok) {
|
|
13616
|
+
btn.textContent = prevText;
|
|
13617
|
+
btn.disabled = false;
|
|
13618
|
+
const fb = document.getElementById('dm-esc-fb-' + dmId);
|
|
13619
|
+
if (fb) {
|
|
13620
|
+
fb.className = 'dm-esc-feedback dm-esc-feedback-err';
|
|
13621
|
+
fb.textContent = (data && data.error) ? ('Failed: ' + data.error) : ('Failed (HTTP ' + resp.status + ')');
|
|
13622
|
+
}
|
|
13623
|
+
return;
|
|
13624
|
+
}
|
|
13625
|
+
const dm = (window.__dmsById || {})[dmId];
|
|
13626
|
+
if (dm) dm.snoozed_until = data && data.snoozed_until || null;
|
|
13627
|
+
const nowSnoozed = !currentlySnoozed;
|
|
13628
|
+
btn.textContent = nowSnoozed ? 'Unskip' : 'Skip until they reply';
|
|
13629
|
+
btn.setAttribute('onclick', 'toggleDmSnooze(this, ' + dmId + ', ' + nowSnoozed + ')');
|
|
13630
|
+
btn.disabled = false;
|
|
13631
|
+
const card = btn.closest('.dm-esc-card');
|
|
13632
|
+
if (card) {
|
|
13633
|
+
const head = card.querySelector('.dm-esc-head');
|
|
13634
|
+
const existingBadge = head ? head.querySelector('.dm-esc-snoozed') : null;
|
|
13635
|
+
if (nowSnoozed && head && !existingBadge) {
|
|
13636
|
+
const badge = document.createElement('span');
|
|
13637
|
+
badge.className = 'dm-esc-snoozed';
|
|
13638
|
+
badge.title = 'Auto-cleared when they send a new inbound.';
|
|
13639
|
+
badge.textContent = 'skipped';
|
|
13640
|
+
head.insertBefore(badge, btn);
|
|
13641
|
+
} else if (!nowSnoozed && existingBadge) {
|
|
13642
|
+
existingBadge.remove();
|
|
13643
|
+
}
|
|
13644
|
+
}
|
|
13645
|
+
} catch (e) {
|
|
13646
|
+
btn.textContent = prevText;
|
|
13647
|
+
btn.disabled = false;
|
|
13648
|
+
const fb = document.getElementById('dm-esc-fb-' + dmId);
|
|
13649
|
+
if (fb) {
|
|
13650
|
+
fb.className = 'dm-esc-feedback dm-esc-feedback-err';
|
|
13651
|
+
fb.textContent = 'Network error: ' + ((e && e.message) || 'unknown');
|
|
13652
|
+
}
|
|
13653
|
+
}
|
|
13654
|
+
}
|
|
13655
|
+
|
|
13419
13656
|
// Cmd/Ctrl+Enter inside any escalation textarea triggers send.
|
|
13420
13657
|
if (!window.__dmEscKeydownInstalled) {
|
|
13421
13658
|
window.__dmEscKeydownInstalled = true;
|
|
@@ -13568,8 +13805,67 @@ const PROJECT_STATUS_PLATFORM_LABELS = {
|
|
|
13568
13805
|
moltbook: 'MoltBook', github: 'GitHub',
|
|
13569
13806
|
};
|
|
13570
13807
|
let _projectStatusLoading = false;
|
|
13808
|
+
let _projectStatusData = null;
|
|
13809
|
+
let _projectStatusOrder = null;
|
|
13810
|
+
const PROJECT_STATUS_SORT_STORAGE = 'sa.projectStatus.sort.v1';
|
|
13811
|
+
let _projectStatusSort = { field: 'weight', dir: 'desc' };
|
|
13812
|
+
try {
|
|
13813
|
+
const saved = JSON.parse(localStorage.getItem(PROJECT_STATUS_SORT_STORAGE) || 'null');
|
|
13814
|
+
if (saved && typeof saved.field === 'string' && (saved.dir === 'asc' || saved.dir === 'desc')) {
|
|
13815
|
+
_projectStatusSort = { field: saved.field, dir: saved.dir };
|
|
13816
|
+
}
|
|
13817
|
+
} catch (e) {}
|
|
13818
|
+
function _persistProjectStatusSort() {
|
|
13819
|
+
try { localStorage.setItem(PROJECT_STATUS_SORT_STORAGE, JSON.stringify(_projectStatusSort)); } catch (e) {}
|
|
13820
|
+
}
|
|
13821
|
+
const PROJECT_STATUS_SORT_FIELDS = {
|
|
13822
|
+
name: { type: 'string', value: r => r.name || '' },
|
|
13823
|
+
weight: { type: 'numeric', value: r => Number(r.weight) || 0 },
|
|
13824
|
+
target_share: { type: 'numeric', value: r => Number(r.target_share) || 0 },
|
|
13825
|
+
total: { type: 'numeric', value: r => Number(r.total) || 0 },
|
|
13826
|
+
cost: { type: 'numeric', value: r => Number(r.cost_usd) || 0 },
|
|
13827
|
+
reddit: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.reddit) || 0 },
|
|
13828
|
+
twitter: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.twitter) || 0 },
|
|
13829
|
+
linkedin: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.linkedin) || 0 },
|
|
13830
|
+
moltbook: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.moltbook) || 0 },
|
|
13831
|
+
github: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.github) || 0 },
|
|
13832
|
+
};
|
|
13833
|
+
function _sortProjectRows(rows) {
|
|
13834
|
+
const { field, dir } = _projectStatusSort;
|
|
13835
|
+
const cfg = PROJECT_STATUS_SORT_FIELDS[field] || PROJECT_STATUS_SORT_FIELDS.weight;
|
|
13836
|
+
const mul = dir === 'asc' ? 1 : -1;
|
|
13837
|
+
return rows.slice().sort((a, b) => {
|
|
13838
|
+
// Unassigned rows always live at the bottom regardless of sort.
|
|
13839
|
+
if (!!a.unassigned !== !!b.unassigned) return a.unassigned ? 1 : -1;
|
|
13840
|
+
const va = cfg.value(a); const vb = cfg.value(b);
|
|
13841
|
+
if (cfg.type === 'numeric') {
|
|
13842
|
+
const diff = (Number(va) - Number(vb)) * mul;
|
|
13843
|
+
if (diff !== 0) return diff;
|
|
13844
|
+
return String(a.name || '').localeCompare(String(b.name || ''));
|
|
13845
|
+
}
|
|
13846
|
+
return String(va).localeCompare(String(vb)) * mul;
|
|
13847
|
+
});
|
|
13848
|
+
}
|
|
13849
|
+
function _applyProjectStatusOrder(rows) {
|
|
13850
|
+
if (!_projectStatusOrder) return _sortProjectRows(rows);
|
|
13851
|
+
// Render previously-captured order, appending any new rows at the bottom
|
|
13852
|
+
// (still respecting unassigned-at-bottom). This is what keeps a freshly-
|
|
13853
|
+
// edited row from jumping after a save: order is frozen until the user
|
|
13854
|
+
// explicitly resorts (header click) or refreshes (↻).
|
|
13855
|
+
const byName = new Map(rows.map(r => [r.name, r]));
|
|
13856
|
+
const used = new Set();
|
|
13857
|
+
const out = [];
|
|
13858
|
+
for (const name of _projectStatusOrder) {
|
|
13859
|
+
const r = byName.get(name);
|
|
13860
|
+
if (r) { out.push(r); used.add(name); }
|
|
13861
|
+
}
|
|
13862
|
+
const leftovers = rows.filter(r => !used.has(r.name));
|
|
13863
|
+
// Keep unassigned at the bottom even within the leftovers slice.
|
|
13864
|
+
leftovers.sort((a, b) => (a.unassigned ? 1 : 0) - (b.unassigned ? 1 : 0));
|
|
13865
|
+
return out.concat(leftovers);
|
|
13866
|
+
}
|
|
13571
13867
|
function formatPct(v) { return (Number(v || 0) * 100).toFixed(1) + '%'; }
|
|
13572
|
-
function renderProjectStatus(data) {
|
|
13868
|
+
function renderProjectStatus(data, opts) {
|
|
13573
13869
|
const body = document.getElementById('project-status-body');
|
|
13574
13870
|
const totalEl = document.getElementById('project-status-total');
|
|
13575
13871
|
const heading = document.getElementById('project-status-heading');
|
|
@@ -13579,6 +13875,8 @@ function renderProjectStatus(data) {
|
|
|
13579
13875
|
body.innerHTML = '<div class="style-stats-empty">' + escapeHtml(data.error) + '</div>';
|
|
13580
13876
|
return;
|
|
13581
13877
|
}
|
|
13878
|
+
_projectStatusData = data;
|
|
13879
|
+
const preserveOrder = !!(opts && opts.preserveOrder);
|
|
13582
13880
|
const hours = Number(data && data.hours) || 24;
|
|
13583
13881
|
if (heading) heading.textContent = 'Project Status (last ' + hours + 'h)';
|
|
13584
13882
|
const projects = (data && data.projects) || [];
|
|
@@ -13638,16 +13936,27 @@ function renderProjectStatus(data) {
|
|
|
13638
13936
|
body.innerHTML = '<div class="style-stats-empty">No projects configured with weight > 0.</div>';
|
|
13639
13937
|
return;
|
|
13640
13938
|
}
|
|
13939
|
+
const sortHeader = (key, label, align) => {
|
|
13940
|
+
const alignStyle = align === 'left' ? 'text-align:left;' : 'text-align:right;';
|
|
13941
|
+
const active = _projectStatusSort.field === key;
|
|
13942
|
+
const arrow = active ? (_projectStatusSort.dir === 'asc' ? '▲' : '▼') : '';
|
|
13943
|
+
const arrowCls = 'activity-sort-arrow' + (active ? ' active' : '');
|
|
13944
|
+
return '<th class="activity-sortable" data-project-sort-key="' + key + '" style="' + alignStyle + '">' +
|
|
13945
|
+
'<span class="activity-header-label">' + label +
|
|
13946
|
+
' <span class="' + arrowCls + '" data-project-sort-arrow="' + key + '">' + arrow + '</span>' +
|
|
13947
|
+
'</span>' +
|
|
13948
|
+
'</th>';
|
|
13949
|
+
};
|
|
13641
13950
|
const header =
|
|
13642
13951
|
'<thead><tr>' +
|
|
13643
|
-
'
|
|
13644
|
-
'
|
|
13645
|
-
'
|
|
13952
|
+
sortHeader('name', 'Project', 'left') +
|
|
13953
|
+
sortHeader('weight', 'Weight') +
|
|
13954
|
+
sortHeader('target_share', 'Target %') +
|
|
13646
13955
|
PROJECT_STATUS_PLATFORMS.map(p =>
|
|
13647
|
-
|
|
13956
|
+
sortHeader(p, PROJECT_STATUS_PLATFORM_LABELS[p])
|
|
13648
13957
|
).join('') +
|
|
13649
|
-
'
|
|
13650
|
-
(costAvailable ? '
|
|
13958
|
+
sortHeader('total', 'Total') +
|
|
13959
|
+
(costAvailable ? sortHeader('cost', 'Cost') : '') +
|
|
13651
13960
|
'</tr></thead>';
|
|
13652
13961
|
const cellWithShare = (n, platformTotal, targetShare, opts) => {
|
|
13653
13962
|
const num = Number(n) || 0;
|
|
@@ -13697,12 +14006,15 @@ function renderProjectStatus(data) {
|
|
|
13697
14006
|
const editable = canEditWeight && (!r.unassigned || r.configured);
|
|
13698
14007
|
const weightCellHtml = editable
|
|
13699
14008
|
? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
|
|
13700
|
-
'<
|
|
13701
|
-
'
|
|
13702
|
-
|
|
13703
|
-
|
|
13704
|
-
|
|
13705
|
-
|
|
14009
|
+
'<span class="pw-cell" data-project-weight-cell="' + escapeHtml(r.name) + '">' +
|
|
14010
|
+
'<input type="number" min="0" step="1" value="' + weightVal + '" ' +
|
|
14011
|
+
'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
|
|
14012
|
+
'data-original-weight="' + weightVal + '" ' +
|
|
14013
|
+
'class="project-weight-input" ' +
|
|
14014
|
+
'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;" ' +
|
|
14015
|
+
'title="Edit and press Enter or blur to save" />' +
|
|
14016
|
+
'<span class="pw-spinner" aria-hidden="true"></span>' +
|
|
14017
|
+
'</span>' +
|
|
13706
14018
|
'</td>'
|
|
13707
14019
|
: '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
|
|
13708
14020
|
return '<tr>' +
|
|
@@ -13714,7 +14026,12 @@ function renderProjectStatus(data) {
|
|
|
13714
14026
|
costCellHtml +
|
|
13715
14027
|
'</tr>';
|
|
13716
14028
|
};
|
|
13717
|
-
const
|
|
14029
|
+
const allRows = projects.concat(unassigned);
|
|
14030
|
+
const ordered = preserveOrder ? _applyProjectStatusOrder(allRows) : _sortProjectRows(allRows);
|
|
14031
|
+
// Capture the order we just rendered so future in-place saves (or
|
|
14032
|
+
// background reloads) don't reshuffle rows under the operator.
|
|
14033
|
+
_projectStatusOrder = ordered.map(r => r.name);
|
|
14034
|
+
const bodyRows = ordered.map(rowHtml).join('');
|
|
13718
14035
|
const footerCells = PROJECT_STATUS_PLATFORMS.map(p =>
|
|
13719
14036
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
|
|
13720
14037
|
).join('');
|
|
@@ -13738,6 +14055,25 @@ function renderProjectStatus(data) {
|
|
|
13738
14055
|
'<tbody>' + bodyRows + footerHtml + '</tbody>' +
|
|
13739
14056
|
'</table>' +
|
|
13740
14057
|
'</div>' + legend;
|
|
14058
|
+
body.querySelectorAll('[data-project-sort-key]').forEach(th => {
|
|
14059
|
+
th.addEventListener('click', () => {
|
|
14060
|
+
const key = th.getAttribute('data-project-sort-key');
|
|
14061
|
+
if (!PROJECT_STATUS_SORT_FIELDS[key]) return;
|
|
14062
|
+
const cfg = PROJECT_STATUS_SORT_FIELDS[key];
|
|
14063
|
+
const defaultDir = cfg.type === 'numeric' ? 'desc' : 'asc';
|
|
14064
|
+
if (_projectStatusSort.field === key) {
|
|
14065
|
+
_projectStatusSort.dir = _projectStatusSort.dir === 'asc' ? 'desc' : 'asc';
|
|
14066
|
+
} else {
|
|
14067
|
+
_projectStatusSort.field = key;
|
|
14068
|
+
_projectStatusSort.dir = defaultDir;
|
|
14069
|
+
}
|
|
14070
|
+
_persistProjectStatusSort();
|
|
14071
|
+
// Header click is an explicit user-initiated re-sort, so drop the
|
|
14072
|
+
// sticky order and let _sortProjectRows recompute.
|
|
14073
|
+
_projectStatusOrder = null;
|
|
14074
|
+
if (_projectStatusData) renderProjectStatus(_projectStatusData);
|
|
14075
|
+
});
|
|
14076
|
+
});
|
|
13741
14077
|
if (canEditWeight) {
|
|
13742
14078
|
body.querySelectorAll('input.project-weight-input').forEach(inp => {
|
|
13743
14079
|
inp.addEventListener('keydown', e => {
|
|
@@ -13761,7 +14097,9 @@ async function saveProjectWeight(inp) {
|
|
|
13761
14097
|
inp.value = String(original);
|
|
13762
14098
|
return;
|
|
13763
14099
|
}
|
|
13764
|
-
if (
|
|
14100
|
+
if (next === original) return;
|
|
14101
|
+
const cell = inp.closest('.pw-cell');
|
|
14102
|
+
if (cell) cell.classList.add('is-saving');
|
|
13765
14103
|
inp.disabled = true;
|
|
13766
14104
|
const prevBorder = inp.style.borderColor;
|
|
13767
14105
|
inp.style.borderColor = 'var(--text-muted)';
|
|
@@ -13783,8 +14121,11 @@ async function saveProjectWeight(inp) {
|
|
|
13783
14121
|
inp.style.borderColor = '#15803d';
|
|
13784
14122
|
setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
|
|
13785
14123
|
try { window.posthog && window.posthog.capture('project_weight_edit', { project: name, weight: next, previous: original }); } catch (er) {}
|
|
14124
|
+
// Pull fresh totals + target % from the server, but preserve the row
|
|
14125
|
+
// order so the just-edited row stays where the operator saw it. The
|
|
14126
|
+
// order will be refreshed on header click or the ↻ refresh button.
|
|
13786
14127
|
_projectStatusLoading = false;
|
|
13787
|
-
loadProjectStatus(true);
|
|
14128
|
+
loadProjectStatus(true, { preserveOrder: true });
|
|
13788
14129
|
} catch (e) {
|
|
13789
14130
|
inp.value = String(original);
|
|
13790
14131
|
inp.style.borderColor = '#b91c1c';
|
|
@@ -13792,6 +14133,7 @@ async function saveProjectWeight(inp) {
|
|
|
13792
14133
|
console.error('[project-weight] save error', e);
|
|
13793
14134
|
} finally {
|
|
13794
14135
|
inp.disabled = false;
|
|
14136
|
+
if (cell) cell.classList.remove('is-saving');
|
|
13795
14137
|
}
|
|
13796
14138
|
}
|
|
13797
14139
|
async function refreshAllData() {
|
|
@@ -13819,7 +14161,7 @@ async function refreshAllData() {
|
|
|
13819
14161
|
loadTopDms(true);
|
|
13820
14162
|
loadActivity();
|
|
13821
14163
|
}
|
|
13822
|
-
async function loadProjectStatus(force) {
|
|
14164
|
+
async function loadProjectStatus(force, opts) {
|
|
13823
14165
|
if (_projectStatusLoading) return;
|
|
13824
14166
|
if (saAuthNotReady()) return;
|
|
13825
14167
|
_projectStatusLoading = true;
|
|
@@ -13827,7 +14169,7 @@ async function loadProjectStatus(force) {
|
|
|
13827
14169
|
const hours = currentStatusWindow().hours;
|
|
13828
14170
|
const res = await fetch('/api/project/status?hours=' + hours);
|
|
13829
14171
|
const data = await res.json();
|
|
13830
|
-
renderProjectStatus(data);
|
|
14172
|
+
renderProjectStatus(data, opts);
|
|
13831
14173
|
} catch (e) {
|
|
13832
14174
|
renderProjectStatus({ error: String(e && e.message || e) });
|
|
13833
14175
|
} finally {
|
|
@@ -14333,7 +14675,10 @@ function saStartApp() {
|
|
|
14333
14675
|
document.body.classList.remove('sa-authed-pending');
|
|
14334
14676
|
const isCloud = document.body.classList.contains('sa-cloud');
|
|
14335
14677
|
const isAdmin = window.SA_IS_ADMIN !== false;
|
|
14336
|
-
if (!isAdmin)
|
|
14678
|
+
if (!isAdmin) {
|
|
14679
|
+
DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
|
|
14680
|
+
RATIO_METRICS = RATIO_METRICS.filter(m => !m.adminOnly);
|
|
14681
|
+
}
|
|
14337
14682
|
try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
|
|
14338
14683
|
// Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
|
|
14339
14684
|
// are admin-only too, so skipping them on cloud also stops 403 spam for
|
|
@@ -14353,7 +14698,10 @@ function saStartApp() {
|
|
|
14353
14698
|
loadDeployHealth();
|
|
14354
14699
|
setInterval(loadDeployHealth, 60000);
|
|
14355
14700
|
loadProjectStatus();
|
|
14356
|
-
|
|
14701
|
+
// Silent background polls preserve the current row order so editing one
|
|
14702
|
+
// weight + waiting 60s doesn't shuffle rows under the operator. Explicit
|
|
14703
|
+
// ↻ refresh and column-header clicks re-sort.
|
|
14704
|
+
setInterval(() => loadProjectStatus(false, { preserveOrder: true }), 60000);
|
|
14357
14705
|
}
|
|
14358
14706
|
// Funnel + DM stats sections are \`<details open>\` by default; load them
|
|
14359
14707
|
// here (post-auth) rather than in their wire IIFEs, which fire before
|