Spaces:
Sleeping
Sleeping
Update csv-viewer app
Browse files- app.py +1 -1
- 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">♦</span> Signal Game</a>
|
| 202 |
<a data-page="coop"><span class="icon">⚔</span> Prisoner + Stag</a>
|
| 203 |
<a data-page="payments"><span class="icon">$</span> Payments</a>
|
|
|
|
| 204 |
<a data-page="diagnostic"><span class="icon">⚠</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">♦</span> Signal Game</a>
|
| 202 |
<a data-page="coop"><span class="icon">⚔</span> Prisoner + Stag</a>
|
| 203 |
<a data-page="payments"><span class="icon">$</span> Payments</a>
|
| 204 |
+
<a data-page="paystats"><span class="icon">⚖</span> Payment Stats</a>
|
| 205 |
<a data-page="diagnostic"><span class="icon">⚠</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 |
|