iurbinah commited on
Commit
e46ea03
·
1 Parent(s): 6407dc3

Update csv-viewer app

Browse files
Files changed (2) hide show
  1. app.py +1 -1
  2. templates/index.html +159 -1
app.py CHANGED
@@ -592,7 +592,7 @@ def api_session_diagnostic():
592
  treat_col = "signal_game.1.group.treatment"
593
 
594
  has_letter = df[df[letter_col].notna()].copy()
595
- has_letter["_time"] = pd.to_datetime(has_letter[time_col])
596
  has_letter["_bucket"] = has_letter["_time"].dt.floor("10min")
597
 
598
  canonical = {}
 
592
  treat_col = "signal_game.1.group.treatment"
593
 
594
  has_letter = df[df[letter_col].notna()].copy()
595
+ has_letter["_time"] = pd.to_datetime(has_letter[time_col], utc=True).dt.tz_convert("America/New_York")
596
  has_letter["_bucket"] = has_letter["_time"].dt.floor("10min")
597
 
598
  canonical = {}
templates/index.html CHANGED
@@ -201,6 +201,7 @@
201
  <a data-page="signal"><span class="icon">&#9830;</span> Signal Game</a>
202
  <a data-page="coop"><span class="icon">&#9876;</span> Prisoner + Stag</a>
203
  <a data-page="payments"><span class="icon">&#36;</span> Payments</a>
 
204
  <a data-page="diagnostic"><span class="icon">&#9888;</span> Session Diagnostic</a>
205
  </nav>
206
  <div class="sidebar-footer">
@@ -322,6 +323,19 @@
322
  </div>
323
  </div>
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  <!-- PAGE 5: Session Diagnostic -->
326
  <div class="page" id="page-diagnostic">
327
  <div class="toolbar">
@@ -354,6 +368,7 @@ $$('.sidebar nav a').forEach(link => {
354
  if (link.dataset.page === 'stats' && !statsLoaded) loadStats();
355
  if (link.dataset.page === 'signal' && !signalLoaded) loadSignal();
356
  if (link.dataset.page === 'coop' && !coopLoaded) loadCoop();
 
357
  if (link.dataset.page === 'diagnostic' && !diagLoaded) loadDiagnostic();
358
  });
359
  });
@@ -375,7 +390,7 @@ async function fetchFromOtree() {
375
  const data = await res.json();
376
  if (data.ok) {
377
  toast(`Fetched ${data.rows} rows, ${data.cols} cols`, true);
378
- await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop(), loadDiagnostic()]);
379
  } else toast(`Fetch failed: ${data.error}`, false);
380
  } catch (e) { toast(`Fetch error: ${e.message}`, false); }
381
  btn.disabled = false; btn.textContent = 'Fetch from oTree';
@@ -861,6 +876,149 @@ document.addEventListener('keydown', (e) => {
861
 
862
  loadPaidState();
863
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
  // ===================== PAGE 3: SIGNAL GAME =====================
865
  let signalLoaded = false;
866
 
 
201
  <a data-page="signal"><span class="icon">&#9830;</span> Signal Game</a>
202
  <a data-page="coop"><span class="icon">&#9876;</span> Prisoner + Stag</a>
203
  <a data-page="payments"><span class="icon">&#36;</span> Payments</a>
204
+ <a data-page="paystats"><span class="icon">&#9878;</span> Payment Stats</a>
205
  <a data-page="diagnostic"><span class="icon">&#9888;</span> Session Diagnostic</a>
206
  </nav>
207
  <div class="sidebar-footer">
 
323
  </div>
324
  </div>
325
 
326
+ <!-- PAGE 6: Payment Stats -->
327
+ <div class="page" id="page-paystats">
328
+ <div class="toolbar">
329
+ <h2>Payment Stats</h2>
330
+ <div class="sep"></div>
331
+ <button class="btn" id="payStatsRefreshBtn">Reload</button>
332
+ <span class="badge" id="payStatsRefreshTime">-</span>
333
+ </div>
334
+ <div class="stats-scroll" id="payStatsBody">
335
+ <div style="color:var(--dim);padding:40px;text-align:center">Loading...</div>
336
+ </div>
337
+ </div>
338
+
339
  <!-- PAGE 5: Session Diagnostic -->
340
  <div class="page" id="page-diagnostic">
341
  <div class="toolbar">
 
368
  if (link.dataset.page === 'stats' && !statsLoaded) loadStats();
369
  if (link.dataset.page === 'signal' && !signalLoaded) loadSignal();
370
  if (link.dataset.page === 'coop' && !coopLoaded) loadCoop();
371
+ if (link.dataset.page === 'paystats' && !payStatsLoaded) loadPayStats();
372
  if (link.dataset.page === 'diagnostic' && !diagLoaded) loadDiagnostic();
373
  });
374
  });
 
390
  const data = await res.json();
391
  if (data.ok) {
392
  toast(`Fetched ${data.rows} rows, ${data.cols} cols`, true);
393
+ await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop(), loadPayStats(), loadDiagnostic()]);
394
  } else toast(`Fetch failed: ${data.error}`, false);
395
  } catch (e) { toast(`Fetch error: ${e.message}`, false); }
396
  btn.disabled = false; btn.textContent = 'Fetch from oTree';
 
876
 
877
  loadPaidState();
878
 
879
+ // ===================== PAGE 6: PAYMENT STATS =====================
880
+ let payStatsLoaded = false;
881
+
882
+ async function loadPayStats() {
883
+ const res = await fetch('/api/payments');
884
+ const P = await res.json();
885
+ payStatsLoaded = true;
886
+
887
+ const bonusIdx = P.columns.indexOf('total_bonus');
888
+ const sidIdx = P.columns.indexOf('participant_session_id');
889
+ const pcIdx = P.columns.indexOf('PC_id');
890
+
891
+ // Filter to rows that have a session id (completed)
892
+ const rows = P.rows.filter(r => String(r[sidIdx]) !== '');
893
+ const bonuses = rows.map(r => parseFloat(r[bonusIdx])).filter(v => !isNaN(v));
894
+ bonuses.sort((a, b) => a - b);
895
+
896
+ const n = bonuses.length;
897
+ const total = bonuses.reduce((s, v) => s + v, 0);
898
+ const mean = n ? total / n : 0;
899
+ const median = n ? (n % 2 === 1 ? bonuses[Math.floor(n / 2)] : (bonuses[n / 2 - 1] + bonuses[n / 2]) / 2) : 0;
900
+ const min = n ? bonuses[0] : 0;
901
+ const max = n ? bonuses[n - 1] : 0;
902
+ const std = n ? Math.sqrt(bonuses.reduce((s, v) => s + (v - mean) ** 2, 0) / n) : 0;
903
+
904
+ const fmt = v => `$${v.toFixed(2)}`;
905
+
906
+ let html = '';
907
+
908
+ // ---- KPI row ----
909
+ html += `<div class="kpi-row">
910
+ <div class="kpi accent"><div class="kpi-val">${n}</div><div class="kpi-label">Participants</div></div>
911
+ <div class="kpi green"><div class="kpi-val">${fmt(total)}</div><div class="kpi-label">Total payout</div></div>
912
+ <div class="kpi purple"><div class="kpi-val">${fmt(mean)}</div><div class="kpi-label">Mean bonus</div></div>
913
+ <div class="kpi"><div class="kpi-val">${fmt(median)}</div><div class="kpi-label">Median bonus</div></div>
914
+ <div class="kpi"><div class="kpi-val">${fmt(min)}</div><div class="kpi-label">Min bonus</div></div>
915
+ <div class="kpi"><div class="kpi-val">${fmt(max)}</div><div class="kpi-label">Max bonus</div></div>
916
+ <div class="kpi orange"><div class="kpi-val">${fmt(std)}</div><div class="kpi-label">Std dev</div></div>
917
+ </div>`;
918
+
919
+ // ---- Paid / Unpaid from localStorage ----
920
+ loadPaidState();
921
+ const paidCount = rows.filter(r => paidSet.has(`${r[sidIdx]}::${r[pcIdx]}`)).length;
922
+ const unpaidCount = n - paidCount;
923
+ const paidPct = n ? (paidCount / n * 100).toFixed(1) : 0;
924
+ html += `<div class="stats-section"><h3>Disbursement progress</h3>
925
+ <div class="kpi-row">
926
+ <div class="kpi green"><div class="kpi-val">${paidCount}</div><div class="kpi-label">Marked paid</div></div>
927
+ <div class="kpi red"><div class="kpi-val">${unpaidCount}</div><div class="kpi-label">Remaining</div></div>
928
+ <div class="kpi accent"><div class="kpi-val">${paidPct}%</div><div class="kpi-label">Disbursed</div></div>
929
+ <div class="kpi green"><div class="kpi-val">${fmt(rows.filter(r => paidSet.has(`${r[sidIdx]}::${r[pcIdx]}`)).reduce((s, r) => s + (parseFloat(r[bonusIdx]) || 0), 0))}</div><div class="kpi-label">Amount paid out</div></div>
930
+ <div class="kpi red"><div class="kpi-val">${fmt(rows.filter(r => !paidSet.has(`${r[sidIdx]}::${r[pcIdx]}`)).reduce((s, r) => s + (parseFloat(r[bonusIdx]) || 0), 0))}</div><div class="kpi-label">Amount remaining</div></div>
931
+ </div>
932
+ </div>`;
933
+
934
+ // ---- Per-session breakdown ----
935
+ const sessionMap = {};
936
+ for (const r of rows) {
937
+ const sid = String(r[sidIdx]);
938
+ if (!sessionMap[sid]) sessionMap[sid] = [];
939
+ const b = parseFloat(r[bonusIdx]);
940
+ if (!isNaN(b)) sessionMap[sid].push({ bonus: b, row: r });
941
+ }
942
+ const sessionIds = Object.keys(sessionMap).sort();
943
+ const maxSessionTotal = Math.max(...sessionIds.map(s => sessionMap[s].reduce((sum, x) => sum + x.bonus, 0)), 1);
944
+
945
+ html += `<div class="stats-section"><h3>Per-session breakdown</h3>
946
+ <table class="session-table">
947
+ <thead><tr><th>Session</th><th>Participants</th><th>Total payout</th><th>Mean bonus</th><th>Median</th><th>Min</th><th>Max</th><th>Paid</th></tr></thead><tbody>`;
948
+ for (const sid of sessionIds) {
949
+ const items = sessionMap[sid];
950
+ const vals = items.map(x => x.bonus).sort((a, b) => a - b);
951
+ const sn = vals.length;
952
+ const sTotal = vals.reduce((s, v) => s + v, 0);
953
+ const sMean = sn ? sTotal / sn : 0;
954
+ const sMedian = sn ? (sn % 2 === 1 ? vals[Math.floor(sn / 2)] : (vals[sn / 2 - 1] + vals[sn / 2]) / 2) : 0;
955
+ const sMin = sn ? vals[0] : 0;
956
+ const sMax = sn ? vals[sn - 1] : 0;
957
+ const sPaid = items.filter(x => paidSet.has(`${sid}::${x.row[pcIdx]}`)).length;
958
+ const barPct = (sTotal / maxSessionTotal * 100).toFixed(0);
959
+ html += `<tr>
960
+ <td><strong>${sid}</strong></td>
961
+ <td>${sn}</td>
962
+ <td><div class="dur-bar-wrap"><div class="dur-bar-bg"><div class="dur-bar" style="width:${barPct}%;background:var(--green)"></div></div>${fmt(sTotal)}</div></td>
963
+ <td>${fmt(sMean)}</td>
964
+ <td>${fmt(sMedian)}</td>
965
+ <td>${fmt(sMin)}</td>
966
+ <td>${fmt(sMax)}</td>
967
+ <td>${sPaid} / ${sn}</td>
968
+ </tr>`;
969
+ }
970
+ html += `</tbody></table></div>`;
971
+
972
+ // ---- Bonus distribution histogram ----
973
+ if (n > 0) {
974
+ const bucketSize = 1; // $1 buckets
975
+ const bucketMin = Math.floor(min);
976
+ const bucketMax = Math.ceil(max);
977
+ const buckets = {};
978
+ for (let b = bucketMin; b <= bucketMax; b += bucketSize) buckets[b] = 0;
979
+ for (const v of bonuses) {
980
+ const bk = Math.floor(v / bucketSize) * bucketSize;
981
+ buckets[bk] = (buckets[bk] || 0) + 1;
982
+ }
983
+ const maxBucket = Math.max(...Object.values(buckets), 1);
984
+ const bucketKeys = Object.keys(buckets).map(Number).sort((a, b) => a - b);
985
+
986
+ html += `<div class="stats-section"><h3>Bonus distribution</h3><div class="funnel">`;
987
+ for (const bk of bucketKeys) {
988
+ const count = buckets[bk];
989
+ if (count === 0) continue;
990
+ const pct = (count / maxBucket * 100).toFixed(1);
991
+ const label = `$${bk.toFixed(0)} – $${(bk + bucketSize).toFixed(0)}`;
992
+ html += `<div class="funnel-row">
993
+ <div class="funnel-label">${label}</div>
994
+ <div class="funnel-bar-bg"><div class="funnel-bar" style="width:${pct}%;background:var(--green)">${count}</div></div>
995
+ <div class="funnel-count">${(count / n * 100).toFixed(0)}%</div>
996
+ </div>`;
997
+ }
998
+ html += `</div></div>`;
999
+
1000
+ // ---- Quartiles / Percentiles ----
1001
+ const p25 = bonuses[Math.floor(n * 0.25)];
1002
+ const p75 = bonuses[Math.floor(n * 0.75)];
1003
+ const p90 = bonuses[Math.floor(n * 0.90)];
1004
+ const iqr = p75 - p25;
1005
+ html += `<div class="stats-section"><h3>Percentiles</h3>
1006
+ <div class="kpi-row">
1007
+ <div class="kpi"><div class="kpi-val">${fmt(p25)}</div><div class="kpi-label">25th percentile</div></div>
1008
+ <div class="kpi purple"><div class="kpi-val">${fmt(median)}</div><div class="kpi-label">50th percentile</div></div>
1009
+ <div class="kpi"><div class="kpi-val">${fmt(p75)}</div><div class="kpi-label">75th percentile</div></div>
1010
+ <div class="kpi"><div class="kpi-val">${fmt(p90)}</div><div class="kpi-label">90th percentile</div></div>
1011
+ <div class="kpi accent"><div class="kpi-val">${fmt(iqr)}</div><div class="kpi-label">IQR (P75 – P25)</div></div>
1012
+ </div>
1013
+ </div>`;
1014
+ }
1015
+
1016
+ $('#payStatsBody').innerHTML = html;
1017
+ $('#payStatsRefreshTime').textContent = new Date().toLocaleTimeString();
1018
+ }
1019
+
1020
+ $('#payStatsRefreshBtn').addEventListener('click', loadPayStats);
1021
+
1022
  // ===================== PAGE 3: SIGNAL GAME =====================
1023
  let signalLoaded = false;
1024