social-autoposter 1.3.5 → 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 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 (!c.batch_id || !batchIds.has(c.batch_id)) continue;
1239
+ if (!ownBatchId || c.batch_id !== ownBatchId) continue;
1187
1240
  candidatesPassed++;
1188
- if (c.status === 'posted') posted++;
1189
- else if (c.status === 'expired') expired++;
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 : [],
@@ -5945,6 +6036,15 @@ const HTML = `<!DOCTYPE html>
5945
6036
  }
5946
6037
  .sa-del-btn { position: relative; }
5947
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; }
5948
6048
  .sa-del-btn.is-pending {
5949
6049
  opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
5950
6050
  background: rgba(245, 158, 11, 0.08);
@@ -7595,6 +7695,12 @@ function renderResult(run) {
7595
7695
  const salvageableLive = r.salvageable_now || 0;
7596
7696
  const salvAdded = r.salvageable_added || 0;
7597
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;
7598
7704
  // Legacy queue fields kept for the tooltip (operator can still see queue
7599
7705
  // depth + drain breakdown if they hover the pill).
7600
7706
  const queue = (r.queue_end != null) ? r.queue_end : (r.pending_queue || 0);
@@ -7621,22 +7727,24 @@ function renderResult(run) {
7621
7727
  'style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
7622
7728
  label + (count ? ' <span style="color:var(--text);font-weight:600;">' + count + '</span>' : '') + '</span>';
7623
7729
  };
7624
- // Salvaged pill with per-run delta. Format: salvaged N (+A/-D)
7625
- // N = live count of pending rows with a persisted draft (Phase 0 salvage
7626
- // pool orphaned drafts that the next cycle's salvage lane will retry).
7627
- // +A = drafts created during this run's window (became salvageable).
7628
- // -D = drafts that exited pending during this run's window.
7629
- // Delta omitted when both sides are zero so old (pre-patch) rows stay
7630
- // clean instead of showing noisy (+0/-0).
7631
- const salvDeltaSuffix = (salvAdded || salvDrained)
7632
- ? ' <span style="color:var(--muted);font-weight:400;">(' +
7633
- '+' + salvAdded + '/-' + salvDrained +
7634
- ')</span>'
7635
- : '';
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
+ }
7636
7744
  const queuePill =
7637
7745
  '<span style="display:inline-block;margin-right:10px;font-size:12px;color:var(--muted);">' +
7638
- 'salvaged <span style="color:var(--text);font-weight:600;">' + salvageableLive + '</span>' +
7639
- salvDeltaSuffix +
7746
+ 'salvaged <span style="color:var(--text);font-weight:600;">' + salvPrimary + '</span>' +
7747
+ salvBracket +
7640
7748
  '</span>';
7641
7749
  const tooltip = 'searches: ' + searches +
7642
7750
  ' / raw tweets: ' + raw +
@@ -7645,7 +7753,9 @@ function renderResult(run) {
7645
7753
  ' / expired (delta<1 floor): ' + expired +
7646
7754
  ' / above review cap (delta>=10, gates POST_LIMIT=3): ' + aboveFloor +
7647
7755
  ' / posted: ' + posted +
7648
- ' / salvageable now (pending+drafted): ' + salvageableLive +
7756
+ ' / Phase 0 salvaged into this cycle: ' + salvAttempted +
7757
+ ' (of which posted: ' + salvPosted + ')' +
7758
+ ' / salvageable now (pool size for next cycle): ' + salvageableLive +
7649
7759
  ' (+' + salvAdded + ' became salvageable / -' + salvDrained + ' drained this run)' +
7650
7760
  ' / pending end-of-run: ' + queue +
7651
7761
  ' (start: ' + queueStart + ', +' + qAdded + ' added, -' + qDrained + ' drained = ' +
@@ -9825,7 +9935,7 @@ function renderDailyMetrics() {
9825
9935
  // the same Trends-tab filters because it reuses _dailyMetricsSeries directly,
9826
9936
  // no new fetch needed. Values are percentages (0-100), formatted to one
9827
9937
  // decimal place; days with views=0 are dropped (ratios are undefined).
9828
- const RATIO_METRICS = [
9938
+ let RATIO_METRICS = [
9829
9939
  { id: 'upvotes_per_view', label: 'Upvotes / Views', color: '#f97316', numerator: 'upvotes', denominator: 'views', format: 'pct', scaleFactor: 100 },
9830
9940
  { id: 'comments_per_view', label: 'Comments / Views', color: '#14b8a6', numerator: 'comments', denominator: 'views', format: 'pct', scaleFactor: 100 },
9831
9941
  { id: 'clicks_per_view', label: 'Clicks / Views', color: '#0ea5e9', numerator: 'clicks', denominator: 'views', format: 'pct', scaleFactor: 100 },
@@ -9841,8 +9951,8 @@ const RATIO_METRICS = [
9841
9951
  // format='usd' switches the legend pill, axis labels, bar labels, and
9842
9952
  // tooltip to dollar rendering. Days with denominator=0 still drop out
9843
9953
  // (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 },
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 },
9846
9956
  ];
9847
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'];
9848
9958
  // .v2: ratio set expanded to include cost_per_kviews + cost_per_kvisitors.
@@ -13568,8 +13678,67 @@ const PROJECT_STATUS_PLATFORM_LABELS = {
13568
13678
  moltbook: 'MoltBook', github: 'GitHub',
13569
13679
  };
13570
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
+ }
13571
13740
  function formatPct(v) { return (Number(v || 0) * 100).toFixed(1) + '%'; }
13572
- function renderProjectStatus(data) {
13741
+ function renderProjectStatus(data, opts) {
13573
13742
  const body = document.getElementById('project-status-body');
13574
13743
  const totalEl = document.getElementById('project-status-total');
13575
13744
  const heading = document.getElementById('project-status-heading');
@@ -13579,6 +13748,8 @@ function renderProjectStatus(data) {
13579
13748
  body.innerHTML = '<div class="style-stats-empty">' + escapeHtml(data.error) + '</div>';
13580
13749
  return;
13581
13750
  }
13751
+ _projectStatusData = data;
13752
+ const preserveOrder = !!(opts && opts.preserveOrder);
13582
13753
  const hours = Number(data && data.hours) || 24;
13583
13754
  if (heading) heading.textContent = 'Project Status (last ' + hours + 'h)';
13584
13755
  const projects = (data && data.projects) || [];
@@ -13638,16 +13809,27 @@ function renderProjectStatus(data) {
13638
13809
  body.innerHTML = '<div class="style-stats-empty">No projects configured with weight &gt; 0.</div>';
13639
13810
  return;
13640
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
+ };
13641
13823
  const header =
13642
13824
  '<thead><tr>' +
13643
- '<th style="text-align:left;">Project</th>' +
13644
- '<th style="text-align:right;">Weight</th>' +
13645
- '<th style="text-align:right;">Target&nbsp;%</th>' +
13825
+ sortHeader('name', 'Project', 'left') +
13826
+ sortHeader('weight', 'Weight') +
13827
+ sortHeader('target_share', 'Target&nbsp;%') +
13646
13828
  PROJECT_STATUS_PLATFORMS.map(p =>
13647
- '<th style="text-align:right;">' + PROJECT_STATUS_PLATFORM_LABELS[p] + '</th>'
13829
+ sortHeader(p, PROJECT_STATUS_PLATFORM_LABELS[p])
13648
13830
  ).join('') +
13649
- '<th style="text-align:right;">Total</th>' +
13650
- (costAvailable ? '<th style="text-align:right;" title="Claude session cost in this window">Cost</th>' : '') +
13831
+ sortHeader('total', 'Total') +
13832
+ (costAvailable ? sortHeader('cost', 'Cost') : '') +
13651
13833
  '</tr></thead>';
13652
13834
  const cellWithShare = (n, platformTotal, targetShare, opts) => {
13653
13835
  const num = Number(n) || 0;
@@ -13697,12 +13879,15 @@ function renderProjectStatus(data) {
13697
13879
  const editable = canEditWeight && (!r.unassigned || r.configured);
13698
13880
  const weightCellHtml = editable
13699
13881
  ? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
13700
- '<input type="number" min="0" step="1" value="' + weightVal + '" ' +
13701
- 'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
13702
- 'data-original-weight="' + weightVal + '" ' +
13703
- 'class="project-weight-input" ' +
13704
- 'style="width:60px;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;" ' +
13705
- 'title="Edit and press Enter or blur to save" />' +
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>' +
13706
13891
  '</td>'
13707
13892
  : '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
13708
13893
  return '<tr>' +
@@ -13714,7 +13899,12 @@ function renderProjectStatus(data) {
13714
13899
  costCellHtml +
13715
13900
  '</tr>';
13716
13901
  };
13717
- const bodyRows = projects.map(rowHtml).join('') + unassigned.map(rowHtml).join('');
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('');
13718
13908
  const footerCells = PROJECT_STATUS_PLATFORMS.map(p =>
13719
13909
  '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (Number(totals[p]) || 0) + '</td>'
13720
13910
  ).join('');
@@ -13738,6 +13928,25 @@ function renderProjectStatus(data) {
13738
13928
  '<tbody>' + bodyRows + footerHtml + '</tbody>' +
13739
13929
  '</table>' +
13740
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
+ });
13741
13950
  if (canEditWeight) {
13742
13951
  body.querySelectorAll('input.project-weight-input').forEach(inp => {
13743
13952
  inp.addEventListener('keydown', e => {
@@ -13761,7 +13970,9 @@ async function saveProjectWeight(inp) {
13761
13970
  inp.value = String(original);
13762
13971
  return;
13763
13972
  }
13764
- if (Math.trunc(next) === Math.trunc(original) && next === original) return;
13973
+ if (next === original) return;
13974
+ const cell = inp.closest('.pw-cell');
13975
+ if (cell) cell.classList.add('is-saving');
13765
13976
  inp.disabled = true;
13766
13977
  const prevBorder = inp.style.borderColor;
13767
13978
  inp.style.borderColor = 'var(--text-muted)';
@@ -13783,8 +13994,11 @@ async function saveProjectWeight(inp) {
13783
13994
  inp.style.borderColor = '#15803d';
13784
13995
  setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
13785
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.
13786
14000
  _projectStatusLoading = false;
13787
- loadProjectStatus(true);
14001
+ loadProjectStatus(true, { preserveOrder: true });
13788
14002
  } catch (e) {
13789
14003
  inp.value = String(original);
13790
14004
  inp.style.borderColor = '#b91c1c';
@@ -13792,6 +14006,7 @@ async function saveProjectWeight(inp) {
13792
14006
  console.error('[project-weight] save error', e);
13793
14007
  } finally {
13794
14008
  inp.disabled = false;
14009
+ if (cell) cell.classList.remove('is-saving');
13795
14010
  }
13796
14011
  }
13797
14012
  async function refreshAllData() {
@@ -13819,7 +14034,7 @@ async function refreshAllData() {
13819
14034
  loadTopDms(true);
13820
14035
  loadActivity();
13821
14036
  }
13822
- async function loadProjectStatus(force) {
14037
+ async function loadProjectStatus(force, opts) {
13823
14038
  if (_projectStatusLoading) return;
13824
14039
  if (saAuthNotReady()) return;
13825
14040
  _projectStatusLoading = true;
@@ -13827,7 +14042,7 @@ async function loadProjectStatus(force) {
13827
14042
  const hours = currentStatusWindow().hours;
13828
14043
  const res = await fetch('/api/project/status?hours=' + hours);
13829
14044
  const data = await res.json();
13830
- renderProjectStatus(data);
14045
+ renderProjectStatus(data, opts);
13831
14046
  } catch (e) {
13832
14047
  renderProjectStatus({ error: String(e && e.message || e) });
13833
14048
  } finally {
@@ -14333,7 +14548,10 @@ function saStartApp() {
14333
14548
  document.body.classList.remove('sa-authed-pending');
14334
14549
  const isCloud = document.body.classList.contains('sa-cloud');
14335
14550
  const isAdmin = window.SA_IS_ADMIN !== false;
14336
- if (!isAdmin) DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
14551
+ if (!isAdmin) {
14552
+ DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
14553
+ RATIO_METRICS = RATIO_METRICS.filter(m => !m.adminOnly);
14554
+ }
14337
14555
  try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
14338
14556
  // Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
14339
14557
  // are admin-only too, so skipping them on cloud also stops 403 spam for
@@ -14353,7 +14571,10 @@ function saStartApp() {
14353
14571
  loadDeployHealth();
14354
14572
  setInterval(loadDeployHealth, 60000);
14355
14573
  loadProjectStatus();
14356
- setInterval(loadProjectStatus, 60000);
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);
14357
14578
  }
14358
14579
  // Funnel + DM stats sections are \`<details open>\` by default; load them
14359
14580
  // here (post-auth) rather than in their wire IIFEs, which fire before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"