Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- .env +2 -0
- Dockerfile +12 -0
- all_apps_wide-2026-03-31.csv +0 -0
- app.py +582 -0
- requirements.txt +6 -0
- templates/index.html +1210 -0
- templates/login.html +30 -0
.env
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
OTREE_CSV_URL=http://otree-lab-games-790d4693d333.herokuapp.com/ExportSessionWide/aqoghhgp?token=6d91a4ad
|
| 2 |
+
ADMIN_PASSWORD=bpel123
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "app:app"]
|
all_apps_wide-2026-03-31.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import requests
|
| 11 |
+
from flask import Flask, render_template, request, Response, session, redirect, url_for
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
app = Flask(__name__)
|
| 15 |
+
app.secret_key = os.environ.get("FLASK_SECRET", os.urandom(24).hex())
|
| 16 |
+
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "bpel123")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.before_request
|
| 20 |
+
def require_login():
|
| 21 |
+
if request.endpoint in ("login", "static"):
|
| 22 |
+
return
|
| 23 |
+
if not session.get("authenticated"):
|
| 24 |
+
if request.path.startswith("/api/"):
|
| 25 |
+
return Response("Unauthorized", status=401)
|
| 26 |
+
return redirect(url_for("login"))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def to_json(obj):
|
| 30 |
+
"""jsonify replacement that handles numpy types."""
|
| 31 |
+
def default(o):
|
| 32 |
+
if isinstance(o, (np.integer,)):
|
| 33 |
+
return int(o)
|
| 34 |
+
if isinstance(o, (np.floating,)):
|
| 35 |
+
if np.isnan(o):
|
| 36 |
+
return None
|
| 37 |
+
return float(o)
|
| 38 |
+
if isinstance(o, np.ndarray):
|
| 39 |
+
return o.tolist()
|
| 40 |
+
raise TypeError(f"{type(o)} not serializable")
|
| 41 |
+
data = json.dumps(obj, default=default)
|
| 42 |
+
return Response(data, mimetype="application/json")
|
| 43 |
+
|
| 44 |
+
CSV_PATH = os.path.join(os.path.dirname(__file__), "all_apps_wide-2026-03-31.csv")
|
| 45 |
+
OTREE_URL = os.environ.get("OTREE_CSV_URL", "")
|
| 46 |
+
|
| 47 |
+
# Identifier columns to always include alongside app_collect_results
|
| 48 |
+
ID_COLS = [
|
| 49 |
+
"participant.id_in_session",
|
| 50 |
+
"participant.code",
|
| 51 |
+
"participant.label",
|
| 52 |
+
"participant._current_app_name",
|
| 53 |
+
"participant._current_page_name",
|
| 54 |
+
"participant.treatment",
|
| 55 |
+
"participant.payoff",
|
| 56 |
+
"session.code",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
COLLECT_PREFIX = "app_collect_results."
|
| 60 |
+
|
| 61 |
+
# In-memory cache so we don't re-read disk on every request
|
| 62 |
+
_cache = {"df": None, "mtime": 0}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _get_df():
|
| 66 |
+
"""Return the dataframe, re-reading from disk only if the file changed."""
|
| 67 |
+
try:
|
| 68 |
+
mtime = os.path.getmtime(CSV_PATH)
|
| 69 |
+
except OSError:
|
| 70 |
+
mtime = 0
|
| 71 |
+
if _cache["df"] is None or mtime != _cache["mtime"]:
|
| 72 |
+
_cache["df"] = pd.read_csv(CSV_PATH)
|
| 73 |
+
_cache["mtime"] = mtime
|
| 74 |
+
return _cache["df"]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def load_csv(collect_only=True):
|
| 78 |
+
df = _get_df().copy()
|
| 79 |
+
if collect_only:
|
| 80 |
+
collect_cols = [c for c in df.columns if c.startswith(COLLECT_PREFIX)]
|
| 81 |
+
keep = [c for c in ID_COLS if c in df.columns] + collect_cols
|
| 82 |
+
df = df[keep]
|
| 83 |
+
return df
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.route("/login", methods=["GET", "POST"])
|
| 87 |
+
def login():
|
| 88 |
+
error = None
|
| 89 |
+
if request.method == "POST":
|
| 90 |
+
if request.form.get("password") == ADMIN_PASSWORD:
|
| 91 |
+
session["authenticated"] = True
|
| 92 |
+
return redirect(url_for("index"))
|
| 93 |
+
error = "Incorrect password"
|
| 94 |
+
return render_template("login.html", error=error)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.route("/logout")
|
| 98 |
+
def logout():
|
| 99 |
+
session.clear()
|
| 100 |
+
return redirect(url_for("login"))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.route("/")
|
| 104 |
+
def index():
|
| 105 |
+
return render_template("index.html")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@app.route("/api/data")
|
| 109 |
+
def api_data():
|
| 110 |
+
collect_only = request.args.get("collect_only", "1") == "1"
|
| 111 |
+
df = load_csv(collect_only=collect_only)
|
| 112 |
+
|
| 113 |
+
display_cols = []
|
| 114 |
+
for c in df.columns:
|
| 115 |
+
if c.startswith(COLLECT_PREFIX):
|
| 116 |
+
parts = c.split(".")
|
| 117 |
+
display_cols.append(parts[-1])
|
| 118 |
+
elif c.startswith("participant."):
|
| 119 |
+
display_cols.append(c.replace("participant.", "p."))
|
| 120 |
+
elif c.startswith("session."):
|
| 121 |
+
display_cols.append(c.replace("session.", "s."))
|
| 122 |
+
else:
|
| 123 |
+
display_cols.append(c)
|
| 124 |
+
|
| 125 |
+
return to_json({
|
| 126 |
+
"columns": display_cols,
|
| 127 |
+
"raw_columns": list(df.columns),
|
| 128 |
+
"rows": df.fillna("").values.tolist(),
|
| 129 |
+
"total": len(df),
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.route("/api/fetch-otree", methods=["POST"])
|
| 134 |
+
def fetch_otree():
|
| 135 |
+
"""Pull fresh CSV from oTree and overwrite the local file."""
|
| 136 |
+
try:
|
| 137 |
+
resp = requests.get(OTREE_URL, timeout=30)
|
| 138 |
+
resp.raise_for_status()
|
| 139 |
+
# Validate it's actually CSV
|
| 140 |
+
df = pd.read_csv(io.StringIO(resp.text))
|
| 141 |
+
# Save to disk
|
| 142 |
+
df.to_csv(CSV_PATH, index=False)
|
| 143 |
+
# Bust cache
|
| 144 |
+
_cache["df"] = df
|
| 145 |
+
_cache["mtime"] = os.path.getmtime(CSV_PATH)
|
| 146 |
+
return to_json({"ok": True, "rows": len(df), "cols": len(df.columns)})
|
| 147 |
+
except Exception as e:
|
| 148 |
+
return to_json({"ok": False, "error": str(e)}), 502
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.route("/api/upload", methods=["POST"])
|
| 152 |
+
def upload_csv():
|
| 153 |
+
"""Accept an uploaded CSV file to replace the current dataset."""
|
| 154 |
+
f = request.files.get("file")
|
| 155 |
+
if not f:
|
| 156 |
+
return to_json({"ok": False, "error": "No file uploaded"}), 400
|
| 157 |
+
try:
|
| 158 |
+
df = pd.read_csv(f.stream)
|
| 159 |
+
df.to_csv(CSV_PATH, index=False)
|
| 160 |
+
_cache["df"] = df
|
| 161 |
+
_cache["mtime"] = os.path.getmtime(CSV_PATH)
|
| 162 |
+
return to_json({"ok": True, "rows": len(df), "cols": len(df.columns)})
|
| 163 |
+
except Exception as e:
|
| 164 |
+
return to_json({"ok": False, "error": str(e)}), 400
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@app.route("/api/payments")
|
| 168 |
+
def api_payments():
|
| 169 |
+
"""Return just PC_id, total_bonus, participant_session_id for the payments page."""
|
| 170 |
+
df = _get_df()
|
| 171 |
+
col_map = {
|
| 172 |
+
"app_collect_results.1.player.PC_id_manual_input": "PC_id",
|
| 173 |
+
"app_collect_results.1.player.total_bonus": "total_bonus",
|
| 174 |
+
"app_collect_results.1.player.participant_session_id": "participant_session_id",
|
| 175 |
+
}
|
| 176 |
+
keep = [c for c in col_map if c in df.columns]
|
| 177 |
+
sub = df[keep].rename(columns=col_map).fillna("")
|
| 178 |
+
|
| 179 |
+
# Distinct session ids sorted alphabetically
|
| 180 |
+
session_ids = sorted(set(str(v) for v in sub["participant_session_id"] if v != ""))
|
| 181 |
+
|
| 182 |
+
return to_json({
|
| 183 |
+
"columns": [col_map[c] for c in keep],
|
| 184 |
+
"rows": sub.values.tolist(),
|
| 185 |
+
"session_ids": session_ids,
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@app.route("/api/stats")
|
| 190 |
+
def api_stats():
|
| 191 |
+
"""Session-level stats dashboard data."""
|
| 192 |
+
df = _get_df().copy()
|
| 193 |
+
|
| 194 |
+
sid_col = "app_collect_results.1.player.participant_session_id"
|
| 195 |
+
app_col = "participant._current_app_name"
|
| 196 |
+
page_col = "participant._current_page_name"
|
| 197 |
+
bot_col = "participant._is_bot"
|
| 198 |
+
orphan_col = "app_collect_results.1.player.was_orphan"
|
| 199 |
+
dropout_col = "participant.midgame_dropout"
|
| 200 |
+
timeout_col = "participant.timed_out_from_coord_games"
|
| 201 |
+
duration_col = "participant.completion_duration_time"
|
| 202 |
+
treatment_col = "participant.treatment"
|
| 203 |
+
|
| 204 |
+
# Only rows with a non-empty participant_session_id = "completed"
|
| 205 |
+
df["_sid"] = df[sid_col].fillna("")
|
| 206 |
+
completed = df[df["_sid"] != ""]
|
| 207 |
+
incomplete = df[df["_sid"] == ""]
|
| 208 |
+
|
| 209 |
+
# App sequence (derived from column order)
|
| 210 |
+
app_order = []
|
| 211 |
+
for c in df.columns:
|
| 212 |
+
parts = c.split(".")
|
| 213 |
+
if len(parts) > 1 and parts[0] not in ("participant", "session") and parts[0] not in app_order:
|
| 214 |
+
app_order.append(parts[0])
|
| 215 |
+
|
| 216 |
+
# ---- Global summary ----
|
| 217 |
+
total = len(df)
|
| 218 |
+
n_completed = len(completed)
|
| 219 |
+
n_incomplete = len(incomplete)
|
| 220 |
+
|
| 221 |
+
durations = pd.to_numeric(completed[duration_col], errors="coerce").dropna()
|
| 222 |
+
|
| 223 |
+
global_summary = {
|
| 224 |
+
"total_rows": total,
|
| 225 |
+
"completed": n_completed,
|
| 226 |
+
"incomplete": n_incomplete,
|
| 227 |
+
"completion_rate": round(n_completed / total * 100, 1) if total else 0,
|
| 228 |
+
"orphans": int((df[orphan_col] == 1).sum()) if orphan_col in df.columns else 0,
|
| 229 |
+
"dropouts": int((pd.to_numeric(df[dropout_col], errors="coerce") == 1).sum()) if dropout_col in df.columns else 0,
|
| 230 |
+
"timed_out": int((pd.to_numeric(df[timeout_col], errors="coerce") == 1).sum()) if timeout_col in df.columns else 0,
|
| 231 |
+
"duration_median": round(durations.median(), 1) if len(durations) else None,
|
| 232 |
+
"duration_mean": round(durations.mean(), 1) if len(durations) else None,
|
| 233 |
+
"duration_min": round(durations.min(), 1) if len(durations) else None,
|
| 234 |
+
"duration_max": round(durations.max(), 1) if len(durations) else None,
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# ---- Per-session breakdown ----
|
| 238 |
+
session_ids = sorted(completed["_sid"].unique())
|
| 239 |
+
per_session = []
|
| 240 |
+
for sid in session_ids:
|
| 241 |
+
s = completed[completed["_sid"] == sid]
|
| 242 |
+
s_dur = pd.to_numeric(s[duration_col], errors="coerce").dropna()
|
| 243 |
+
per_session.append({
|
| 244 |
+
"session_id": sid,
|
| 245 |
+
"n": len(s),
|
| 246 |
+
"orphans": int((s[orphan_col] == 1).sum()) if orphan_col in s.columns else 0,
|
| 247 |
+
"duration_median": round(s_dur.median(), 1) if len(s_dur) else None,
|
| 248 |
+
"duration_mean": round(s_dur.mean(), 1) if len(s_dur) else None,
|
| 249 |
+
"treatments": dict(s[treatment_col].fillna("(none)").value_counts()),
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
# ---- Current app/page funnel (where are people RIGHT NOW) ----
|
| 253 |
+
app_page = df[[app_col, page_col, "_sid"]].fillna("(empty)")
|
| 254 |
+
funnel = []
|
| 255 |
+
for a in app_order:
|
| 256 |
+
sub = app_page[app_page[app_col] == a]
|
| 257 |
+
if len(sub) == 0:
|
| 258 |
+
continue
|
| 259 |
+
pages = dict(sub[page_col].value_counts())
|
| 260 |
+
funnel.append({"app": a, "count": len(sub), "pages": pages})
|
| 261 |
+
# Also count those whose current_app is empty
|
| 262 |
+
empty_app = app_page[app_page[app_col] == "(empty)"]
|
| 263 |
+
if len(empty_app):
|
| 264 |
+
funnel.append({"app": "(no app)", "count": len(empty_app), "pages": {}})
|
| 265 |
+
|
| 266 |
+
# ---- Completed players: which app they finished at (current_app) ----
|
| 267 |
+
completed_funnel = []
|
| 268 |
+
for a in app_order:
|
| 269 |
+
n = int((completed[app_col] == a).sum())
|
| 270 |
+
if n:
|
| 271 |
+
completed_funnel.append({"app": a, "count": n})
|
| 272 |
+
|
| 273 |
+
return to_json({
|
| 274 |
+
"global": global_summary,
|
| 275 |
+
"per_session": per_session,
|
| 276 |
+
"funnel": funnel,
|
| 277 |
+
"completed_funnel": completed_funnel,
|
| 278 |
+
"app_order": app_order,
|
| 279 |
+
})
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@app.route("/api/signal")
|
| 283 |
+
def api_signal():
|
| 284 |
+
"""Signal game analysis for completed participants."""
|
| 285 |
+
df = _get_df()
|
| 286 |
+
|
| 287 |
+
sid_col = "app_collect_results.1.player.participant_session_id"
|
| 288 |
+
treat_col = "signal_game.1.group.treatment"
|
| 289 |
+
buys_col = "signal_game.1.player.buys_signal"
|
| 290 |
+
color_col = "participant.signal_color_choice"
|
| 291 |
+
skip_col = "participant.skip_signal_game"
|
| 292 |
+
intend_col = "signal_game.1.player.intends_to_buy"
|
| 293 |
+
intend_count_col = "signal_game.1.group.intend_count"
|
| 294 |
+
group_id_col = "signal_game.1.group.id_in_subsession"
|
| 295 |
+
success_col = "signal_game.1.group.group_success"
|
| 296 |
+
beliefs_col = "signal_game.1.player.beliefs_truthful"
|
| 297 |
+
reason_col = "signal_game.1.player.purchase_reason"
|
| 298 |
+
guess_col = "signal_game.1.player.guess_correct"
|
| 299 |
+
psid_col = "app_collect_results.1.player.participant_session_id"
|
| 300 |
+
|
| 301 |
+
# Filter: completed participants (non-empty session letter)
|
| 302 |
+
completed = df[df[sid_col].notna()].copy()
|
| 303 |
+
total_completed = len(completed)
|
| 304 |
+
|
| 305 |
+
# Played signal game = did not skip (skip == 0 or NaN with color present)
|
| 306 |
+
played = completed[completed[color_col].notna()].copy()
|
| 307 |
+
skipped = completed[~completed.index.isin(played.index)]
|
| 308 |
+
total_played = len(played)
|
| 309 |
+
total_skipped = len(skipped)
|
| 310 |
+
|
| 311 |
+
# Treatment labels
|
| 312 |
+
played["_treat"] = played[treat_col].map({0.0: "Control (0)", 1.0: "Treatment (1)"}).fillna("Unknown")
|
| 313 |
+
|
| 314 |
+
# ---- Treatment distribution ----
|
| 315 |
+
treat_dist = dict(played["_treat"].value_counts())
|
| 316 |
+
|
| 317 |
+
# ---- Buy rate by treatment ----
|
| 318 |
+
buy_by_treat = {}
|
| 319 |
+
for treat_name, group in played.groupby("_treat"):
|
| 320 |
+
n = len(group)
|
| 321 |
+
bought = int((group[buys_col] == 1).sum())
|
| 322 |
+
buy_by_treat[treat_name] = {
|
| 323 |
+
"n": n,
|
| 324 |
+
"bought": bought,
|
| 325 |
+
"pct": round(bought / n * 100, 1) if n else 0,
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
# ---- Color distribution by treatment ----
|
| 329 |
+
color_by_treat = {}
|
| 330 |
+
for treat_name, group in played.groupby("_treat"):
|
| 331 |
+
colors = dict(group[color_col].value_counts())
|
| 332 |
+
color_by_treat[treat_name] = colors
|
| 333 |
+
|
| 334 |
+
# ---- Overall color distribution ----
|
| 335 |
+
overall_colors = dict(played[color_col].value_counts())
|
| 336 |
+
|
| 337 |
+
# ---- Overall buy rate ----
|
| 338 |
+
total_bought = int((played[buys_col] == 1).sum())
|
| 339 |
+
overall_buy_pct = round(total_bought / total_played * 100, 1) if total_played else 0
|
| 340 |
+
|
| 341 |
+
# ---- Intend to buy (among those who have the field) ----
|
| 342 |
+
intend_df = played[played[intend_col].notna()]
|
| 343 |
+
intend_yes = int((intend_df[intend_col] == 1).sum())
|
| 344 |
+
intend_no = int((intend_df[intend_col] == 0).sum())
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ---- Group success by treatment ----
|
| 349 |
+
success_by_treat = {}
|
| 350 |
+
for treat_name, group in played.groupby("_treat"):
|
| 351 |
+
n = len(group)
|
| 352 |
+
succ = int((group[success_col] == 1).sum())
|
| 353 |
+
success_by_treat[treat_name] = {
|
| 354 |
+
"n": n,
|
| 355 |
+
"success": succ,
|
| 356 |
+
"pct": round(succ / n * 100, 1) if n else 0,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
# ---- Success rate by group intend_count (treatment only) ----
|
| 360 |
+
treat_played = played[played["_treat"] == "Treatment (1)"]
|
| 361 |
+
treat_groups = treat_played.drop_duplicates(subset=[group_id_col])
|
| 362 |
+
treat_groups = treat_groups[treat_groups[intend_count_col].notna()]
|
| 363 |
+
success_by_intend = {}
|
| 364 |
+
for ic, g in treat_groups.groupby(intend_count_col):
|
| 365 |
+
n = len(g)
|
| 366 |
+
succ = int((g[success_col] == 1).sum())
|
| 367 |
+
success_by_intend[str(int(ic))] = {
|
| 368 |
+
"n_groups": n,
|
| 369 |
+
"success": succ,
|
| 370 |
+
"pct": round(succ / n * 100, 1) if n else 0,
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
# ---- Buy rate by others' intent count (treatment only) ----
|
| 374 |
+
tp = treat_played[treat_played[intend_count_col].notna() & treat_played[intend_col].notna()].copy()
|
| 375 |
+
tp["_others_intend"] = tp[intend_count_col] - tp[intend_col]
|
| 376 |
+
buy_by_others_intend = {}
|
| 377 |
+
for oi, g in tp.groupby("_others_intend"):
|
| 378 |
+
n = len(g)
|
| 379 |
+
bought = int((g[buys_col] == 1).sum())
|
| 380 |
+
buy_by_others_intend[str(int(oi))] = {
|
| 381 |
+
"n": n,
|
| 382 |
+
"bought": bought,
|
| 383 |
+
"pct": round(bought / n * 100, 1) if n else 0,
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# ---- Buy rate by group intend_count (treatment only, per player) ----
|
| 388 |
+
buy_by_group_intend = {}
|
| 389 |
+
for ic, g in tp.groupby(intend_count_col):
|
| 390 |
+
n = len(g)
|
| 391 |
+
bought = int((g[buys_col] == 1).sum())
|
| 392 |
+
buy_by_group_intend[str(int(ic))] = {
|
| 393 |
+
"n": n,
|
| 394 |
+
"bought": bought,
|
| 395 |
+
"pct": round(bought / n * 100, 1) if n else 0,
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
# ---- Beliefs distribution (among those who answered) ----
|
| 399 |
+
beliefs_df = played[played[beliefs_col].notna()]
|
| 400 |
+
beliefs_dist = {str(int(k)): int(v) for k, v in beliefs_df[beliefs_col].value_counts().items()}
|
| 401 |
+
|
| 402 |
+
# ---- By session letter ----
|
| 403 |
+
per_session = []
|
| 404 |
+
for sid in sorted(played[psid_col].unique()):
|
| 405 |
+
s = played[played[psid_col] == sid]
|
| 406 |
+
n = len(s)
|
| 407 |
+
bought = int((s[buys_col] == 1).sum())
|
| 408 |
+
treats = dict(s["_treat"].value_counts())
|
| 409 |
+
colors = dict(s[color_col].value_counts())
|
| 410 |
+
per_session.append({
|
| 411 |
+
"session_id": sid,
|
| 412 |
+
"n": n,
|
| 413 |
+
"bought": bought,
|
| 414 |
+
"buy_pct": round(bought / n * 100, 1) if n else 0,
|
| 415 |
+
"treatments": treats,
|
| 416 |
+
"colors": colors,
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
return to_json({
|
| 420 |
+
"total_completed": total_completed,
|
| 421 |
+
"total_played": total_played,
|
| 422 |
+
"total_skipped": total_skipped,
|
| 423 |
+
"treat_dist": treat_dist,
|
| 424 |
+
"buy_by_treat": buy_by_treat,
|
| 425 |
+
"color_by_treat": color_by_treat,
|
| 426 |
+
"overall_colors": overall_colors,
|
| 427 |
+
"overall_buy_pct": overall_buy_pct,
|
| 428 |
+
"total_bought": total_bought,
|
| 429 |
+
"intend_yes": intend_yes,
|
| 430 |
+
"intend_no": intend_no,
|
| 431 |
+
"success_by_treat": success_by_treat,
|
| 432 |
+
"success_by_intend": success_by_intend,
|
| 433 |
+
"buy_by_others_intend": buy_by_others_intend,
|
| 434 |
+
"buy_by_group_intend": buy_by_group_intend,
|
| 435 |
+
"beliefs_dist": beliefs_dist,
|
| 436 |
+
"per_session": per_session,
|
| 437 |
+
})
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
@app.route("/api/coop-games")
|
| 441 |
+
def api_coop_games():
|
| 442 |
+
"""Combined Prisoner's Dilemma + Stag Hunt dashboard data."""
|
| 443 |
+
df = _get_df()
|
| 444 |
+
|
| 445 |
+
sid_col = "app_collect_results.1.player.participant_session_id"
|
| 446 |
+
|
| 447 |
+
def analyze_game(prefix):
|
| 448 |
+
treat_col = f"{prefix}.1.player.treatment_cond"
|
| 449 |
+
coop_col = f"{prefix}.1.player.cooperate"
|
| 450 |
+
bot_col = f"{prefix}.1.player.is_bot"
|
| 451 |
+
payoff_col = f"{prefix}.1.player.payoff"
|
| 452 |
+
msg_col = f"{prefix}.1.player.messages"
|
| 453 |
+
comp_col = f"{prefix}.1.player.comprehension_passed"
|
| 454 |
+
gpwait_col = f"{prefix}.1.player.time_spent_gpwait"
|
| 455 |
+
instr_col = f"{prefix}.1.player.time_spent_game_instr_page"
|
| 456 |
+
group_col = f"{prefix}.1.player.persistent_group_id"
|
| 457 |
+
|
| 458 |
+
# Filter: non-NaN treatment_cond
|
| 459 |
+
played = df[df[treat_col].notna()].copy()
|
| 460 |
+
n = len(played)
|
| 461 |
+
|
| 462 |
+
# Treatment distribution
|
| 463 |
+
treat_dist = dict(played[treat_col].value_counts())
|
| 464 |
+
|
| 465 |
+
# Overall cooperation
|
| 466 |
+
n_coop = int((played[coop_col] == 1).sum())
|
| 467 |
+
n_defect = int((played[coop_col] == 0).sum())
|
| 468 |
+
coop_rate = round(n_coop / n * 100, 1) if n else 0
|
| 469 |
+
|
| 470 |
+
# Cooperation by treatment
|
| 471 |
+
coop_by_treat = {}
|
| 472 |
+
for t, g in played.groupby(treat_col):
|
| 473 |
+
gn = len(g)
|
| 474 |
+
gc = int((g[coop_col] == 1).sum())
|
| 475 |
+
coop_by_treat[t] = {"n": gn, "coop": gc, "pct": round(gc / gn * 100, 1) if gn else 0}
|
| 476 |
+
|
| 477 |
+
# Human vs bot cooperation by treatment (for bot conditions)
|
| 478 |
+
human_bot_coop = {}
|
| 479 |
+
for t, g in played.groupby(treat_col):
|
| 480 |
+
if bot_col in g.columns and g[bot_col].sum() > 0:
|
| 481 |
+
humans = g[g[bot_col] == 0]
|
| 482 |
+
bots = g[g[bot_col] == 1]
|
| 483 |
+
hc = int((humans[coop_col] == 1).sum())
|
| 484 |
+
bc = int((bots[coop_col] == 1).sum())
|
| 485 |
+
human_bot_coop[t] = {
|
| 486 |
+
"human_n": len(humans), "human_coop": hc,
|
| 487 |
+
"human_pct": round(hc / len(humans) * 100, 1) if len(humans) else 0,
|
| 488 |
+
"bot_n": len(bots), "bot_coop": bc,
|
| 489 |
+
"bot_pct": round(bc / len(bots) * 100, 1) if len(bots) else 0,
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
# Bots vs humans overall
|
| 493 |
+
n_bots = int((played[bot_col] == 1).sum()) if bot_col in played.columns else 0
|
| 494 |
+
n_humans = n - n_bots
|
| 495 |
+
|
| 496 |
+
# Payoff distribution
|
| 497 |
+
payoff_dist = {}
|
| 498 |
+
for t, g in played.groupby(treat_col):
|
| 499 |
+
vals = pd.to_numeric(g[payoff_col], errors="coerce").dropna()
|
| 500 |
+
payoff_dist[t] = {
|
| 501 |
+
"mean": round(vals.mean(), 2) if len(vals) else None,
|
| 502 |
+
"values": dict(vals.value_counts().sort_index()),
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
# Messages: how many non-empty per condition
|
| 506 |
+
msg_by_treat = {}
|
| 507 |
+
if msg_col in played.columns:
|
| 508 |
+
for t, g in played.groupby(treat_col):
|
| 509 |
+
msgs = g[msg_col].fillna("")
|
| 510 |
+
non_empty = int((msgs.str.len() > 0).sum())
|
| 511 |
+
msg_by_treat[t] = {"n": len(g), "with_msg": non_empty}
|
| 512 |
+
|
| 513 |
+
# Comprehension
|
| 514 |
+
comp_passed = int((played[comp_col] == 1).sum()) if comp_col in played.columns else n
|
| 515 |
+
comp_failed = n - comp_passed
|
| 516 |
+
|
| 517 |
+
# Per-session
|
| 518 |
+
per_session = []
|
| 519 |
+
for sid in sorted(played[sid_col].dropna().unique()):
|
| 520 |
+
s = played[played[sid_col] == sid]
|
| 521 |
+
sn = len(s)
|
| 522 |
+
sc = int((s[coop_col] == 1).sum())
|
| 523 |
+
treats = {}
|
| 524 |
+
for t, g in s.groupby(treat_col):
|
| 525 |
+
gn = len(g)
|
| 526 |
+
gc = int((g[coop_col] == 1).sum())
|
| 527 |
+
treats[t] = {"n": gn, "coop": gc, "pct": round(gc / gn * 100, 1) if gn else 0}
|
| 528 |
+
per_session.append({
|
| 529 |
+
"session_id": sid,
|
| 530 |
+
"n": sn,
|
| 531 |
+
"coop": sc,
|
| 532 |
+
"coop_pct": round(sc / sn * 100, 1) if sn else 0,
|
| 533 |
+
"treatments": treats,
|
| 534 |
+
})
|
| 535 |
+
|
| 536 |
+
return {
|
| 537 |
+
"n": n,
|
| 538 |
+
"n_humans": n_humans,
|
| 539 |
+
"n_bots": n_bots,
|
| 540 |
+
"n_coop": n_coop,
|
| 541 |
+
"n_defect": n_defect,
|
| 542 |
+
"coop_rate": coop_rate,
|
| 543 |
+
"treat_dist": treat_dist,
|
| 544 |
+
"coop_by_treat": coop_by_treat,
|
| 545 |
+
"human_bot_coop": human_bot_coop,
|
| 546 |
+
"payoff_dist": payoff_dist,
|
| 547 |
+
"msg_by_treat": msg_by_treat,
|
| 548 |
+
"comp_passed": comp_passed,
|
| 549 |
+
"comp_failed": comp_failed,
|
| 550 |
+
"per_session": per_session,
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
prisoner = analyze_game("app_prisoner")
|
| 554 |
+
stag = analyze_game("app_stag")
|
| 555 |
+
|
| 556 |
+
# Condition labels for the frontend
|
| 557 |
+
cond_labels = {
|
| 558 |
+
"condition_1": "Human + Chat",
|
| 559 |
+
"condition_2": "Human + No Chat",
|
| 560 |
+
"condition_3": "Bot + No Chat",
|
| 561 |
+
"condition_4": "Bot + Chat",
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
return to_json({
|
| 565 |
+
"prisoner": prisoner,
|
| 566 |
+
"stag": stag,
|
| 567 |
+
"cond_labels": cond_labels,
|
| 568 |
+
})
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@app.route("/api/columns")
|
| 572 |
+
def api_columns():
|
| 573 |
+
df = _get_df()
|
| 574 |
+
groups = {}
|
| 575 |
+
for c in df.columns:
|
| 576 |
+
prefix = c.split(".")[0]
|
| 577 |
+
groups.setdefault(prefix, []).append(c)
|
| 578 |
+
return to_json(groups)
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
if __name__ == "__main__":
|
| 582 |
+
app.run(host="0.0.0.0", port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.1.3
|
| 2 |
+
numpy==2.4.4
|
| 3 |
+
pandas==3.0.2
|
| 4 |
+
requests==2.33.1
|
| 5 |
+
gunicorn==23.0.0
|
| 6 |
+
python-dotenv==1.1.0
|
templates/index.html
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>oTree CSV Viewer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg: #0f1117; --surface: #1a1d27; --sidebar: #14161e; --border: #2a2d3a; --text: #e0e0e0; --dim: #888; --accent: #6c9bff; --accent2: #4a7adf; --green: #4caf80; --red: #e05555; --orange: #e0a030; --purple: #9b7fdf; }
|
| 9 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); font-size: 14px; display: flex; height: 100vh; overflow: hidden; }
|
| 11 |
+
|
| 12 |
+
/* ---- sidebar ---- */
|
| 13 |
+
.sidebar { width: 200px; min-width: 200px; background: var(--sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
|
| 14 |
+
.sidebar .logo { padding: 16px 16px 12px; font-size: 15px; font-weight: 700; color: var(--accent); border-bottom: 1px solid var(--border); }
|
| 15 |
+
.sidebar nav { flex: 1; padding: 8px 0; }
|
| 16 |
+
.sidebar nav a { display: flex; align-items: center; gap: 10px; padding: 10px 16px; color: var(--dim); text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; transition: all .15s; cursor: pointer; }
|
| 17 |
+
.sidebar nav a:hover { color: var(--text); background: rgba(108,155,255,.05); }
|
| 18 |
+
.sidebar nav a.active { color: var(--accent); border-left-color: var(--accent); background: rgba(108,155,255,.08); }
|
| 19 |
+
.sidebar nav a .icon { font-size: 16px; width: 20px; text-align: center; }
|
| 20 |
+
.sidebar .sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border); }
|
| 21 |
+
.sidebar .sidebar-footer .btn { width: 100%; font-size: 12px; padding: 7px 10px; }
|
| 22 |
+
|
| 23 |
+
/* ---- main ---- */
|
| 24 |
+
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
| 25 |
+
.page { display: none; flex-direction: column; flex: 1; overflow: hidden; }
|
| 26 |
+
.page.active { display: flex; }
|
| 27 |
+
|
| 28 |
+
/* ---- toolbar ---- */
|
| 29 |
+
.toolbar { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border); flex-wrap: wrap; min-height: 46px; }
|
| 30 |
+
.toolbar h2 { font-size: 14px; font-weight: 600; color: var(--text); white-space: nowrap; }
|
| 31 |
+
.toolbar input[type="text"] { background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 5px 10px; border-radius: 4px; font-size: 13px; width: 220px; }
|
| 32 |
+
.toolbar label { font-size: 12px; color: var(--dim); display: flex; align-items: center; gap: 5px; cursor: pointer; white-space: nowrap; }
|
| 33 |
+
.toolbar input[type="checkbox"] { accent-color: var(--accent); }
|
| 34 |
+
.badge { background: var(--accent2); color: #fff; padding: 2px 8px; border-radius: 10px; font-size: 12px; white-space: nowrap; }
|
| 35 |
+
.btn { background: var(--accent2); color: #fff; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
| 36 |
+
.btn:hover { background: var(--accent); }
|
| 37 |
+
.btn-green { background: var(--green); }
|
| 38 |
+
.btn-green:hover { background: #5dc090; }
|
| 39 |
+
.btn-dim { background: var(--border); color: var(--dim); }
|
| 40 |
+
.btn-dim:hover { background: #3a3d4a; color: var(--text); }
|
| 41 |
+
.sep { width: 1px; height: 22px; background: var(--border); }
|
| 42 |
+
|
| 43 |
+
/* ---- toast ---- */
|
| 44 |
+
.toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; border-radius: 6px; font-size: 13px; z-index: 999; color: #fff; opacity: 0; transition: opacity .3s; pointer-events: none; }
|
| 45 |
+
.toast.show { opacity: 1; }
|
| 46 |
+
.toast.ok { background: var(--green); }
|
| 47 |
+
.toast.err { background: var(--red); }
|
| 48 |
+
|
| 49 |
+
/* ---- table ---- */
|
| 50 |
+
.table-wrap { flex: 1; overflow: auto; }
|
| 51 |
+
table { width: 100%; border-collapse: collapse; }
|
| 52 |
+
thead { position: sticky; top: 0; z-index: 10; }
|
| 53 |
+
th { background: var(--surface); padding: 8px 10px; text-align: left; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; color: var(--dim); border-bottom: 2px solid var(--border); cursor: pointer; white-space: nowrap; user-select: none; }
|
| 54 |
+
th:hover { color: var(--accent); }
|
| 55 |
+
th .arrow { margin-left: 4px; font-size: 10px; }
|
| 56 |
+
td { padding: 5px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 13px; }
|
| 57 |
+
tr:hover td { background: rgba(108,155,255,.07); }
|
| 58 |
+
|
| 59 |
+
.filter-row th { padding: 3px 3px 6px; }
|
| 60 |
+
.filter-row input { width: 100%; min-width: 60px; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 3px 5px; border-radius: 3px; font-size: 11px; }
|
| 61 |
+
|
| 62 |
+
td.val-bot { color: var(--red); font-weight: 600; }
|
| 63 |
+
td.val-orphan { color: var(--orange); font-weight: 600; }
|
| 64 |
+
|
| 65 |
+
/* ---- carousel delivery mode ---- */
|
| 66 |
+
.carousel-wrap { display: none; flex: 1; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; padding: 20px; gap: 20px; }
|
| 67 |
+
.carousel-wrap.active { display: flex; }
|
| 68 |
+
.carousel-progress { font-size: 13px; color: var(--dim); display: flex; align-items: center; gap: 12px; }
|
| 69 |
+
.carousel-progress .prog-bar { width: 200px; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
|
| 70 |
+
.carousel-progress .prog-fill { height: 100%; background: var(--green); border-radius: 3px; transition: width .3s; }
|
| 71 |
+
.carousel-track { display: flex; align-items: center; justify-content: center; gap: 24px; width: 100%; max-width: 1100px; overflow: hidden; }
|
| 72 |
+
.carousel-track .carousel-inner { display: flex; align-items: center; justify-content: center; gap: 24px; width: 100%; transition: transform .25s cubic-bezier(.25,.1,.25,1); }
|
| 73 |
+
.carousel-card { background: var(--surface); border: 2px solid var(--border); border-radius: 16px; padding: 32px 40px; display: flex; flex-direction: column; align-items: center; gap: 16px; transition: all .3s; position: relative; }
|
| 74 |
+
.carousel-card.phantom { opacity: 0.3; filter: blur(1.5px); transform: scale(0.8); width: 200px; min-width: 200px; pointer-events: none; }
|
| 75 |
+
.carousel-card.phantom .cc-pc { font-size: 28px; }
|
| 76 |
+
.carousel-card.phantom .cc-amount { font-size: 22px; }
|
| 77 |
+
.carousel-card.focus { width: 360px; min-width: 360px; border-color: var(--accent); box-shadow: 0 0 30px rgba(108,155,255,.15); }
|
| 78 |
+
.carousel-card.paid { opacity: 0.25; border-color: var(--green); }
|
| 79 |
+
.carousel-card.focus.paid { opacity: 0.7; border-color: var(--green); box-shadow: 0 0 30px rgba(76,175,128,.15); }
|
| 80 |
+
.cc-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--dim); }
|
| 81 |
+
.cc-pc { font-size: 48px; font-weight: 800; font-family: 'Consolas', 'Courier New', monospace; color: var(--accent); background: var(--bg); padding: 8px 24px; border-radius: 10px; border: 2px solid var(--border); letter-spacing: 2px; }
|
| 82 |
+
.cc-amount { font-size: 38px; font-weight: 700; color: var(--green); font-variant-numeric: tabular-nums; }
|
| 83 |
+
.cc-session { font-size: 12px; color: var(--dim); }
|
| 84 |
+
.cc-paid-badge { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--green); padding: 4px 16px; border: 2px solid var(--green); border-radius: 20px; }
|
| 85 |
+
.carousel-nav { display: flex; align-items: center; gap: 16px; }
|
| 86 |
+
.carousel-nav .btn { font-size: 18px; padding: 10px 20px; }
|
| 87 |
+
.btn-paid { background: var(--green); font-size: 15px; padding: 10px 28px; font-weight: 700; }
|
| 88 |
+
.btn-paid:hover { background: #5dc090; }
|
| 89 |
+
.btn-paid.is-paid { background: var(--border); color: var(--dim); }
|
| 90 |
+
.carousel-keys { font-size: 11px; color: var(--dim); }
|
| 91 |
+
|
| 92 |
+
/* ---- session picker ---- */
|
| 93 |
+
.session-picker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border); align-items: center; }
|
| 94 |
+
.session-picker .label { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; margin-right: 4px; }
|
| 95 |
+
.session-chip { padding: 4px 12px; border-radius: 14px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); background: var(--bg); color: var(--dim); cursor: pointer; transition: all .15s; user-select: none; }
|
| 96 |
+
.session-chip:hover { border-color: var(--accent); color: var(--text); }
|
| 97 |
+
.session-chip.active { background: var(--accent2); border-color: var(--accent2); color: #fff; }
|
| 98 |
+
.session-chip.default-pick { box-shadow: 0 0 0 1px var(--green); }
|
| 99 |
+
|
| 100 |
+
/* ---- status bar ---- */
|
| 101 |
+
.status-bar { background: var(--surface); border-top: 1px solid var(--border); padding: 4px 16px; font-size: 12px; color: var(--dim); display: flex; justify-content: space-between; }
|
| 102 |
+
|
| 103 |
+
#fileInput { display: none; }
|
| 104 |
+
|
| 105 |
+
/* ======== STATS PAGE ======== */
|
| 106 |
+
.stats-scroll { flex: 1; overflow: auto; padding: 20px; }
|
| 107 |
+
|
| 108 |
+
/* KPI cards row */
|
| 109 |
+
.kpi-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
|
| 110 |
+
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; min-width: 140px; flex: 1; }
|
| 111 |
+
.kpi .kpi-val { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
| 112 |
+
.kpi .kpi-label { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
|
| 113 |
+
.kpi.green .kpi-val { color: var(--green); }
|
| 114 |
+
.kpi.red .kpi-val { color: var(--red); }
|
| 115 |
+
.kpi.orange .kpi-val { color: var(--orange); }
|
| 116 |
+
.kpi.accent .kpi-val { color: var(--accent); }
|
| 117 |
+
.kpi.purple .kpi-val { color: var(--purple); }
|
| 118 |
+
|
| 119 |
+
/* Section headings */
|
| 120 |
+
.stats-section { margin-bottom: 24px; }
|
| 121 |
+
.stats-section h3 { font-size: 13px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
| 122 |
+
|
| 123 |
+
/* Funnel bar chart */
|
| 124 |
+
.funnel { display: flex; flex-direction: column; gap: 6px; }
|
| 125 |
+
.funnel-row { display: flex; align-items: center; gap: 10px; }
|
| 126 |
+
.funnel-label { width: 220px; min-width: 220px; font-size: 12px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 127 |
+
.funnel-bar-bg { flex: 1; background: var(--surface); border-radius: 4px; height: 24px; position: relative; overflow: hidden; border: 1px solid var(--border); }
|
| 128 |
+
.funnel-bar { height: 100%; border-radius: 3px; transition: width .4s ease; display: flex; align-items: center; padding-left: 8px; font-size: 11px; font-weight: 600; color: #fff; min-width: 28px; }
|
| 129 |
+
.funnel-bar.completed { background: var(--green); }
|
| 130 |
+
.funnel-bar.current { background: var(--accent2); }
|
| 131 |
+
.funnel-count { width: 50px; font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; }
|
| 132 |
+
.funnel-pages { font-size: 11px; color: var(--dim); margin-left: 230px; margin-top: -2px; margin-bottom: 4px; }
|
| 133 |
+
|
| 134 |
+
/* Per-session table */
|
| 135 |
+
.session-table { width: 100%; border-collapse: collapse; }
|
| 136 |
+
.session-table th { background: var(--surface); padding: 8px 12px; font-size: 11px; text-transform: uppercase; color: var(--dim); border-bottom: 2px solid var(--border); text-align: left; cursor: default; }
|
| 137 |
+
.session-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; font-variant-numeric: tabular-nums; }
|
| 138 |
+
.session-table tr:hover td { background: rgba(108,155,255,.05); }
|
| 139 |
+
.session-table .treat-chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
| 140 |
+
.treat-chip { padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(155,127,223,.15); color: var(--purple); border: 1px solid rgba(155,127,223,.3); }
|
| 141 |
+
|
| 142 |
+
/* Duration bar */
|
| 143 |
+
.dur-bar-wrap { display: flex; align-items: center; gap: 8px; }
|
| 144 |
+
.dur-bar-bg { width: 100px; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
|
| 145 |
+
.dur-bar { height: 100%; background: var(--accent); border-radius: 4px; }
|
| 146 |
+
|
| 147 |
+
/* ---- Signal game ---- */
|
| 148 |
+
.donut-row { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
|
| 149 |
+
.donut-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; min-width: 260px; flex: 1; }
|
| 150 |
+
#page-coop .donut-card, #page-signal .donut-card { max-width: calc(33.333% - 14px); box-sizing: border-box; }
|
| 151 |
+
.donut-card h4 { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 12px; }
|
| 152 |
+
.hbar { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
| 153 |
+
.hbar-label { width: 110px; min-width: 110px; font-size: 12px; color: var(--dim); text-align: right; white-space: nowrap; }
|
| 154 |
+
.hbar-track { flex: 1; height: 22px; background: var(--bg); border-radius: 4px; overflow: hidden; border: 1px solid var(--border); position: relative; }
|
| 155 |
+
.hbar-fill { height: 100%; border-radius: 3px; display: flex; align-items: center; padding-left: 8px; font-size: 11px; font-weight: 600; color: #fff; min-width: 2px; transition: width .4s; }
|
| 156 |
+
.hbar-val { min-width: 120px; font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
| 157 |
+
.color-blue { background: #4a88e5; }
|
| 158 |
+
.color-red { background: #e05555; }
|
| 159 |
+
.color-yellow { background: #d4a830; }
|
| 160 |
+
.color-green { background: var(--green); }
|
| 161 |
+
.color-unknown { background: var(--dim); }
|
| 162 |
+
.color-ctrl { background: var(--accent2); }
|
| 163 |
+
.color-treat { background: var(--purple); }
|
| 164 |
+
.pct-ring { display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: 50%; font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
| 165 |
+
.pct-ring.green { background: conic-gradient(var(--green) var(--pct), var(--border) var(--pct)); color: var(--green); }
|
| 166 |
+
.pct-ring.accent { background: conic-gradient(var(--accent) var(--pct), var(--border) var(--pct)); color: var(--accent); }
|
| 167 |
+
.pct-inner { background: var(--surface); width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
| 168 |
+
|
| 169 |
+
/* ---- Coop games side-by-side ---- */
|
| 170 |
+
.game-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
|
| 171 |
+
.game-col { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; }
|
| 172 |
+
.game-col h3 { font-size: 14px; font-weight: 700; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
| 173 |
+
.game-col h3.prisoner { color: var(--red); }
|
| 174 |
+
.game-col h3.stag { color: var(--green); }
|
| 175 |
+
.game-col h4 { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; margin: 14px 0 8px; }
|
| 176 |
+
.coop-bar { display: flex; height: 28px; border-radius: 4px; overflow: hidden; margin-bottom: 4px; border: 1px solid var(--border); }
|
| 177 |
+
.coop-bar .seg { display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: #fff; transition: width .4s; }
|
| 178 |
+
.coop-bar .seg.coop { background: var(--green); }
|
| 179 |
+
.coop-bar .seg.defect { background: var(--red); }
|
| 180 |
+
.cond-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
| 181 |
+
.cond-label { width: 130px; min-width: 130px; font-size: 12px; color: var(--dim); text-align: right; }
|
| 182 |
+
.cond-bar-wrap { flex: 1; }
|
| 183 |
+
.cond-stats { font-size: 11px; color: var(--dim); width: 80px; text-align: right; font-variant-numeric: tabular-nums; }
|
| 184 |
+
.mini-legend { display: flex; gap: 14px; font-size: 11px; color: var(--dim); margin-bottom: 10px; }
|
| 185 |
+
.mini-legend span::before { content: ''; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
| 186 |
+
.mini-legend .l-coop::before { background: var(--green); }
|
| 187 |
+
.mini-legend .l-defect::before { background: var(--red); }
|
| 188 |
+
.mini-legend .l-human::before { background: var(--accent); }
|
| 189 |
+
.mini-legend .l-bot::before { background: var(--purple); }
|
| 190 |
+
</style>
|
| 191 |
+
</head>
|
| 192 |
+
<body>
|
| 193 |
+
|
| 194 |
+
<!-- Sidebar -->
|
| 195 |
+
<div class="sidebar">
|
| 196 |
+
<div class="logo">oTree Viewer</div>
|
| 197 |
+
<nav>
|
| 198 |
+
<a data-page="stats"><span class="icon">◇</span> Session Stats</a>
|
| 199 |
+
<a class="active" data-page="results"><span class="icon">▦</span> Collect Results</a>
|
| 200 |
+
<a data-page="signal"><span class="icon">♦</span> Signal Game</a>
|
| 201 |
+
<a data-page="coop"><span class="icon">⚔</span> Prisoner + Stag</a>
|
| 202 |
+
<a data-page="payments"><span class="icon">$</span> Payments</a>
|
| 203 |
+
</nav>
|
| 204 |
+
<div class="sidebar-footer">
|
| 205 |
+
<button class="btn btn-green" id="fetchBtn" style="width:100%;margin-bottom:6px">Fetch from oTree</button>
|
| 206 |
+
<button class="btn btn-dim" id="uploadBtn" style="width:100%">Upload CSV</button>
|
| 207 |
+
<input type="file" id="fileInput" accept=".csv">
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<!-- Main -->
|
| 212 |
+
<div class="main">
|
| 213 |
+
|
| 214 |
+
<!-- PAGE 0: STATS DASHBOARD -->
|
| 215 |
+
<div class="page" id="page-stats">
|
| 216 |
+
<div class="toolbar">
|
| 217 |
+
<h2>Session Stats</h2>
|
| 218 |
+
<div class="sep"></div>
|
| 219 |
+
<button class="btn" id="statsRefreshBtn">Reload</button>
|
| 220 |
+
<span class="badge" id="statsRefreshTime">-</span>
|
| 221 |
+
</div>
|
| 222 |
+
<div class="stats-scroll" id="statsBody">
|
| 223 |
+
<div style="color:var(--dim);padding:40px;text-align:center">Loading...</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
<!-- PAGE 1: Collect Results -->
|
| 228 |
+
<div class="page active" id="page-results">
|
| 229 |
+
<div class="toolbar">
|
| 230 |
+
<h2>app_collect_results</h2>
|
| 231 |
+
<div class="sep"></div>
|
| 232 |
+
<input type="text" id="globalSearch" placeholder="Search all columns...">
|
| 233 |
+
<label><input type="checkbox" id="showAllCols"> All columns</label>
|
| 234 |
+
<label><input type="checkbox" id="hideBots"> Hide bots</label>
|
| 235 |
+
<button class="btn" id="refreshBtn">Reload</button>
|
| 236 |
+
<span class="badge" id="rowCount">-</span>
|
| 237 |
+
</div>
|
| 238 |
+
<div class="table-wrap">
|
| 239 |
+
<table>
|
| 240 |
+
<thead>
|
| 241 |
+
<tr id="headerRow"></tr>
|
| 242 |
+
<tr class="filter-row" id="filterRow"></tr>
|
| 243 |
+
</thead>
|
| 244 |
+
<tbody id="tbody"></tbody>
|
| 245 |
+
</table>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="status-bar">
|
| 248 |
+
<span id="fileLabel">all_apps_wide-2026-03-31.csv</span>
|
| 249 |
+
<span id="lastRefresh"></span>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<!-- PAGE 3: Signal Game -->
|
| 254 |
+
<div class="page" id="page-signal">
|
| 255 |
+
<div class="toolbar">
|
| 256 |
+
<h2>Signal Game Analysis</h2>
|
| 257 |
+
<div class="sep"></div>
|
| 258 |
+
<button class="btn" id="sigRefreshBtn">Reload</button>
|
| 259 |
+
<span class="badge" id="sigRefreshTime">-</span>
|
| 260 |
+
</div>
|
| 261 |
+
<div class="stats-scroll" id="sigBody">
|
| 262 |
+
<div style="color:var(--dim);padding:40px;text-align:center">Loading...</div>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<!-- PAGE 4: Prisoner + Stag -->
|
| 267 |
+
<div class="page" id="page-coop">
|
| 268 |
+
<div class="toolbar">
|
| 269 |
+
<h2>Prisoner's Dilemma + Stag Hunt</h2>
|
| 270 |
+
<div class="sep"></div>
|
| 271 |
+
<button class="btn" id="coopRefreshBtn">Reload</button>
|
| 272 |
+
<span class="badge" id="coopRefreshTime">-</span>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="stats-scroll" id="coopBody">
|
| 275 |
+
<div style="color:var(--dim);padding:40px;text-align:center">Loading...</div>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<!-- PAGE 2: Payments -->
|
| 280 |
+
<div class="page" id="page-payments">
|
| 281 |
+
<div class="toolbar">
|
| 282 |
+
<h2>Payments</h2>
|
| 283 |
+
<div class="sep"></div>
|
| 284 |
+
<input type="text" id="paySearch" placeholder="Search...">
|
| 285 |
+
<button class="btn" id="payRefreshBtn">Reload</button>
|
| 286 |
+
<button class="btn btn-green" id="carouselToggle">Delivery Mode</button>
|
| 287 |
+
<span class="badge" id="payRowCount">-</span>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="session-picker" id="sessionPicker">
|
| 290 |
+
<span class="label">Session:</span>
|
| 291 |
+
</div>
|
| 292 |
+
<div class="table-wrap">
|
| 293 |
+
<table>
|
| 294 |
+
<thead>
|
| 295 |
+
<tr id="payHeaderRow"></tr>
|
| 296 |
+
<tr class="filter-row" id="payFilterRow"></tr>
|
| 297 |
+
</thead>
|
| 298 |
+
<tbody id="payTbody"></tbody>
|
| 299 |
+
</table>
|
| 300 |
+
</div>
|
| 301 |
+
<!-- Carousel delivery mode -->
|
| 302 |
+
<div class="carousel-wrap" id="carouselWrap">
|
| 303 |
+
<div class="carousel-progress">
|
| 304 |
+
<span id="carouselCount">0 / 0 paid</span>
|
| 305 |
+
<div class="prog-bar"><div class="prog-fill" id="carouselProgFill"></div></div>
|
| 306 |
+
<span id="carouselSession"></span>
|
| 307 |
+
</div>
|
| 308 |
+
<div class="carousel-track" id="carouselTrack"></div>
|
| 309 |
+
<div class="carousel-nav">
|
| 310 |
+
<button class="btn btn-dim" id="carPrev">◀ Prev</button>
|
| 311 |
+
<button class="btn btn-paid" id="carPaidBtn">Mark Paid</button>
|
| 312 |
+
<button class="btn btn-dim" id="carNext">Next ▶</button>
|
| 313 |
+
</div>
|
| 314 |
+
<div class="carousel-keys">Arrow keys to navigate · Space to mark paid · Esc to exit</div>
|
| 315 |
+
</div>
|
| 316 |
+
|
| 317 |
+
<div class="status-bar">
|
| 318 |
+
<span>Payments view</span>
|
| 319 |
+
<span id="payLastRefresh"></span>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
<div id="toast" class="toast"></div>
|
| 326 |
+
|
| 327 |
+
<script>
|
| 328 |
+
const $ = s => document.querySelector(s);
|
| 329 |
+
const $$ = s => document.querySelectorAll(s);
|
| 330 |
+
|
| 331 |
+
// ===================== PAGE NAVIGATION =====================
|
| 332 |
+
$$('.sidebar nav a').forEach(link => {
|
| 333 |
+
link.addEventListener('click', () => {
|
| 334 |
+
$$('.sidebar nav a').forEach(a => a.classList.remove('active'));
|
| 335 |
+
link.classList.add('active');
|
| 336 |
+
$$('.page').forEach(p => p.classList.remove('active'));
|
| 337 |
+
$(`#page-${link.dataset.page}`).classList.add('active');
|
| 338 |
+
if (link.dataset.page === 'payments' && PAY.columns.length === 0) loadPayments();
|
| 339 |
+
if (link.dataset.page === 'stats' && !statsLoaded) loadStats();
|
| 340 |
+
if (link.dataset.page === 'signal' && !signalLoaded) loadSignal();
|
| 341 |
+
if (link.dataset.page === 'coop' && !coopLoaded) loadCoop();
|
| 342 |
+
});
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
// ===================== TOAST =====================
|
| 346 |
+
function toast(msg, ok) {
|
| 347 |
+
const el = $('#toast');
|
| 348 |
+
el.textContent = msg;
|
| 349 |
+
el.className = `toast show ${ok ? 'ok' : 'err'}`;
|
| 350 |
+
setTimeout(() => el.className = 'toast', 3000);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// ===================== FETCH / UPLOAD =====================
|
| 354 |
+
async function fetchFromOtree() {
|
| 355 |
+
const btn = $('#fetchBtn');
|
| 356 |
+
btn.disabled = true; btn.textContent = 'Fetching...';
|
| 357 |
+
try {
|
| 358 |
+
const res = await fetch('/api/fetch-otree', { method: 'POST' });
|
| 359 |
+
const data = await res.json();
|
| 360 |
+
if (data.ok) {
|
| 361 |
+
toast(`Fetched ${data.rows} rows, ${data.cols} cols`, true);
|
| 362 |
+
await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop()]);
|
| 363 |
+
} else toast(`Fetch failed: ${data.error}`, false);
|
| 364 |
+
} catch (e) { toast(`Fetch error: ${e.message}`, false); }
|
| 365 |
+
btn.disabled = false; btn.textContent = 'Fetch from oTree';
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
async function uploadFile(file) {
|
| 369 |
+
const form = new FormData(); form.append('file', file);
|
| 370 |
+
try {
|
| 371 |
+
const res = await fetch('/api/upload', { method: 'POST', body: form });
|
| 372 |
+
const data = await res.json();
|
| 373 |
+
if (data.ok) { toast(`Uploaded ${data.rows} rows`, true); await Promise.all([loadData(), loadPayments(), loadStats()]); }
|
| 374 |
+
else toast(`Upload failed: ${data.error}`, false);
|
| 375 |
+
} catch (e) { toast(`Upload error: ${e.message}`, false); }
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
$('#fetchBtn').addEventListener('click', fetchFromOtree);
|
| 379 |
+
$('#uploadBtn').addEventListener('click', () => $('#fileInput').click());
|
| 380 |
+
$('#fileInput').addEventListener('change', e => { if (e.target.files[0]) uploadFile(e.target.files[0]); e.target.value = ''; });
|
| 381 |
+
|
| 382 |
+
// ===================== PAGE 0: STATS DASHBOARD =====================
|
| 383 |
+
let statsLoaded = false;
|
| 384 |
+
|
| 385 |
+
function fmtDur(secs) {
|
| 386 |
+
if (secs == null) return '-';
|
| 387 |
+
const m = Math.floor(secs / 60), s = Math.round(secs % 60);
|
| 388 |
+
return `${m}m ${s < 10 ? '0' : ''}${s}s`;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
async function loadStats() {
|
| 392 |
+
const res = await fetch('/api/stats');
|
| 393 |
+
const S = await res.json();
|
| 394 |
+
statsLoaded = true;
|
| 395 |
+
const g = S.global;
|
| 396 |
+
const maxFunnel = Math.max(...S.funnel.map(f => f.count), 1);
|
| 397 |
+
const maxDur = S.per_session.length ? Math.max(...S.per_session.map(s => s.duration_mean || 0)) : 1;
|
| 398 |
+
|
| 399 |
+
let html = '';
|
| 400 |
+
|
| 401 |
+
// ---- KPI row ----
|
| 402 |
+
html += `<div class="kpi-row">
|
| 403 |
+
<div class="kpi accent"><div class="kpi-val">${g.total_rows}</div><div class="kpi-label">Total participants</div></div>
|
| 404 |
+
<div class="kpi green"><div class="kpi-val">${g.completed}</div><div class="kpi-label">Completed</div></div>
|
| 405 |
+
<div class="kpi"><div class="kpi-val">${g.incomplete}</div><div class="kpi-label">Incomplete</div></div>
|
| 406 |
+
<div class="kpi green"><div class="kpi-val">${g.completion_rate}%</div><div class="kpi-label">Completion rate</div></div>
|
| 407 |
+
<div class="kpi orange"><div class="kpi-val">${g.orphans}</div><div class="kpi-label">Orphans</div></div>
|
| 408 |
+
<div class="kpi red"><div class="kpi-val">${g.dropouts}</div><div class="kpi-label">Dropouts</div></div>
|
| 409 |
+
<div class="kpi red"><div class="kpi-val">${g.timed_out}</div><div class="kpi-label">Timed out</div></div>
|
| 410 |
+
</div>`;
|
| 411 |
+
|
| 412 |
+
// ---- Duration KPIs ----
|
| 413 |
+
html += `<div class="kpi-row">
|
| 414 |
+
<div class="kpi purple"><div class="kpi-val">${fmtDur(g.duration_median)}</div><div class="kpi-label">Median duration</div></div>
|
| 415 |
+
<div class="kpi"><div class="kpi-val">${fmtDur(g.duration_mean)}</div><div class="kpi-label">Mean duration</div></div>
|
| 416 |
+
<div class="kpi"><div class="kpi-val">${fmtDur(g.duration_min)}</div><div class="kpi-label">Fastest</div></div>
|
| 417 |
+
<div class="kpi"><div class="kpi-val">${fmtDur(g.duration_max)}</div><div class="kpi-label">Slowest</div></div>
|
| 418 |
+
</div>`;
|
| 419 |
+
|
| 420 |
+
// ---- Current position funnel ----
|
| 421 |
+
html += `<div class="stats-section"><h3>Where are participants right now?</h3><div class="funnel">`;
|
| 422 |
+
for (const f of S.funnel) {
|
| 423 |
+
const pct = (f.count / maxFunnel * 100).toFixed(1);
|
| 424 |
+
const pages = Object.entries(f.pages).sort((a,b) => b[1] - a[1]).map(([p,n]) => `${p} (${n})`).join(', ');
|
| 425 |
+
html += `<div class="funnel-row">
|
| 426 |
+
<div class="funnel-label" title="${f.app}">${f.app.replace('app_','')}</div>
|
| 427 |
+
<div class="funnel-bar-bg"><div class="funnel-bar current" style="width:${pct}%">${f.count}</div></div>
|
| 428 |
+
<div class="funnel-count">${pct}%</div>
|
| 429 |
+
</div>`;
|
| 430 |
+
if (pages) html += `<div class="funnel-pages">${pages}</div>`;
|
| 431 |
+
}
|
| 432 |
+
html += `</div></div>`;
|
| 433 |
+
|
| 434 |
+
// ---- Per-session breakdown ----
|
| 435 |
+
html += `<div class="stats-section"><h3>Per-session breakdown (completed only)</h3>
|
| 436 |
+
<table class="session-table">
|
| 437 |
+
<thead><tr><th>Session</th><th>Completed</th><th>Orphans</th><th>Mean duration</th><th>Median duration</th><th>Treatments</th></tr></thead><tbody>`;
|
| 438 |
+
for (const s of S.per_session) {
|
| 439 |
+
const durPct = maxDur ? ((s.duration_mean || 0) / maxDur * 100).toFixed(0) : 0;
|
| 440 |
+
const treats = Object.entries(s.treatments).map(([t,n]) => `<span class="treat-chip">${t} (${n})</span>`).join('');
|
| 441 |
+
html += `<tr>
|
| 442 |
+
<td><strong>${s.session_id}</strong></td>
|
| 443 |
+
<td>${s.n}</td>
|
| 444 |
+
<td>${s.orphans || 0}</td>
|
| 445 |
+
<td><div class="dur-bar-wrap"><div class="dur-bar-bg"><div class="dur-bar" style="width:${durPct}%"></div></div>${fmtDur(s.duration_mean)}</div></td>
|
| 446 |
+
<td>${fmtDur(s.duration_median)}</td>
|
| 447 |
+
<td><div class="treat-chips">${treats}</div></td>
|
| 448 |
+
</tr>`;
|
| 449 |
+
}
|
| 450 |
+
html += `</tbody></table></div>`;
|
| 451 |
+
|
| 452 |
+
// ---- App pipeline ----
|
| 453 |
+
html += `<div class="stats-section"><h3>App pipeline order</h3>
|
| 454 |
+
<div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center">`;
|
| 455 |
+
S.app_order.forEach((a, i) => {
|
| 456 |
+
html += `<span style="background:var(--surface);border:1px solid var(--border);padding:4px 10px;border-radius:4px;font-size:12px;color:var(--text)">${i+1}. ${a.replace('app_','')}</span>`;
|
| 457 |
+
if (i < S.app_order.length - 1) html += `<span style="color:var(--dim);font-size:10px">▶</span>`;
|
| 458 |
+
});
|
| 459 |
+
html += `</div></div>`;
|
| 460 |
+
|
| 461 |
+
$('#statsBody').innerHTML = html;
|
| 462 |
+
$('#statsRefreshTime').textContent = new Date().toLocaleTimeString();
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
$('#statsRefreshBtn').addEventListener('click', loadStats);
|
| 466 |
+
|
| 467 |
+
// ===================== PAGE 1: COLLECT RESULTS =====================
|
| 468 |
+
let DATA = { columns: [], rows: [], raw_columns: [] };
|
| 469 |
+
let sortCol = -1, sortAsc = true, colFilters = [];
|
| 470 |
+
|
| 471 |
+
async function loadData() {
|
| 472 |
+
const allCols = $('#showAllCols').checked;
|
| 473 |
+
const res = await fetch(`/api/data?collect_only=${allCols ? '0' : '1'}`);
|
| 474 |
+
DATA = await res.json();
|
| 475 |
+
colFilters = DATA.columns.map(() => '');
|
| 476 |
+
sortCol = -1;
|
| 477 |
+
buildHeader(); renderResults();
|
| 478 |
+
$('#lastRefresh').textContent = `Refreshed ${new Date().toLocaleTimeString()}`;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function buildHeader() {
|
| 482 |
+
const hr = $('#headerRow'), fr = $('#filterRow');
|
| 483 |
+
hr.innerHTML = DATA.columns.map((c, i) =>
|
| 484 |
+
`<th data-col="${i}" title="${DATA.raw_columns[i]}">${c}<span class="arrow"></span></th>`
|
| 485 |
+
).join('');
|
| 486 |
+
fr.innerHTML = DATA.columns.map((_, i) =>
|
| 487 |
+
`<th><input type="text" data-col="${i}" placeholder="filter"></th>`
|
| 488 |
+
).join('');
|
| 489 |
+
hr.querySelectorAll('th').forEach(th =>
|
| 490 |
+
th.addEventListener('click', () => { const ci = +th.dataset.col; if (sortCol === ci) sortAsc = !sortAsc; else { sortCol = ci; sortAsc = true; } renderResults(); })
|
| 491 |
+
);
|
| 492 |
+
fr.querySelectorAll('input').forEach(inp =>
|
| 493 |
+
inp.addEventListener('input', () => { colFilters[+inp.dataset.col] = inp.value.toLowerCase(); renderResults(); })
|
| 494 |
+
);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function renderResults() {
|
| 498 |
+
const globalQ = $('#globalSearch').value.toLowerCase();
|
| 499 |
+
const hideBots = $('#hideBots').checked;
|
| 500 |
+
const botIdx = DATA.columns.indexOf('is_bot');
|
| 501 |
+
let rows = DATA.rows;
|
| 502 |
+
|
| 503 |
+
if (hideBots && botIdx !== -1) rows = rows.filter(r => String(r[botIdx]) !== '1' && String(r[botIdx]).toLowerCase() !== 'true');
|
| 504 |
+
rows = rows.filter(r => colFilters.every((f, i) => !f || String(r[i]).toLowerCase().includes(f)));
|
| 505 |
+
if (globalQ) rows = rows.filter(r => r.some(v => String(v).toLowerCase().includes(globalQ)));
|
| 506 |
+
|
| 507 |
+
if (sortCol >= 0) {
|
| 508 |
+
rows = [...rows].sort((a, b) => {
|
| 509 |
+
let va = a[sortCol], vb = b[sortCol];
|
| 510 |
+
if (va === '' && vb === '') return 0; if (va === '') return 1; if (vb === '') return -1;
|
| 511 |
+
const na = parseFloat(va), nb = parseFloat(vb);
|
| 512 |
+
if (!isNaN(na) && !isNaN(nb)) return sortAsc ? na - nb : nb - na;
|
| 513 |
+
return sortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
| 514 |
+
});
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
$('#headerRow').querySelectorAll('th').forEach(th => {
|
| 518 |
+
const ci = +th.dataset.col;
|
| 519 |
+
th.querySelector('.arrow').textContent = ci === sortCol ? (sortAsc ? '\u25B2' : '\u25BC') : '';
|
| 520 |
+
});
|
| 521 |
+
|
| 522 |
+
const orphanIdx = DATA.columns.indexOf('was_orphan');
|
| 523 |
+
$('#tbody').innerHTML = rows.map(r =>
|
| 524 |
+
`<tr>${r.map((v, ci) => {
|
| 525 |
+
let cls = '';
|
| 526 |
+
if (ci === botIdx && (String(v) === '1' || String(v).toLowerCase() === 'true')) cls = ' class="val-bot"';
|
| 527 |
+
if (ci === orphanIdx && (String(v) === '1' || String(v).toLowerCase() === 'true')) cls = ' class="val-orphan"';
|
| 528 |
+
return `<td${cls}>${v}</td>`;
|
| 529 |
+
}).join('')}</tr>`
|
| 530 |
+
).join('');
|
| 531 |
+
|
| 532 |
+
$('#rowCount').textContent = `${rows.length} / ${DATA.total} rows`;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
$('#globalSearch').addEventListener('input', renderResults);
|
| 536 |
+
$('#hideBots').addEventListener('change', renderResults);
|
| 537 |
+
$('#showAllCols').addEventListener('change', loadData);
|
| 538 |
+
$('#refreshBtn').addEventListener('click', loadData);
|
| 539 |
+
|
| 540 |
+
// ===================== PAGE 2: PAYMENTS =====================
|
| 541 |
+
let PAY = { columns: [], rows: [], session_ids: [] };
|
| 542 |
+
let paySortCol = -1, paySortAsc = true, payColFilters = [];
|
| 543 |
+
let selectedSessions = new Set();
|
| 544 |
+
|
| 545 |
+
async function loadPayments() {
|
| 546 |
+
const res = await fetch('/api/payments');
|
| 547 |
+
PAY = await res.json();
|
| 548 |
+
payColFilters = PAY.columns.map(() => '');
|
| 549 |
+
paySortCol = -1;
|
| 550 |
+
|
| 551 |
+
selectedSessions.clear();
|
| 552 |
+
const saved = localStorage.getItem('selectedSessions');
|
| 553 |
+
if (saved) {
|
| 554 |
+
JSON.parse(saved).forEach(id => { if (PAY.session_ids.includes(id)) selectedSessions.add(id); });
|
| 555 |
+
}
|
| 556 |
+
if (selectedSessions.size === 0 && PAY.session_ids.length > 0) {
|
| 557 |
+
selectedSessions.add(PAY.session_ids[PAY.session_ids.length - 1]);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
buildSessionPicker();
|
| 561 |
+
buildPayHeader();
|
| 562 |
+
renderPayments();
|
| 563 |
+
if (localStorage.getItem('deliveryMode') === '1' && !carouselActive) toggleCarousel();
|
| 564 |
+
$('#payLastRefresh').textContent = `Refreshed ${new Date().toLocaleTimeString()}`;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function buildSessionPicker() {
|
| 568 |
+
const picker = $('#sessionPicker');
|
| 569 |
+
const lastId = PAY.session_ids.length > 0 ? PAY.session_ids[PAY.session_ids.length - 1] : null;
|
| 570 |
+
picker.innerHTML = '<span class="label">Session:</span>' +
|
| 571 |
+
PAY.session_ids.map(id => {
|
| 572 |
+
const active = selectedSessions.has(id) ? ' active' : '';
|
| 573 |
+
const dflt = id === lastId ? ' default-pick' : '';
|
| 574 |
+
return `<span class="session-chip${active}${dflt}" data-sid="${id}">${id}</span>`;
|
| 575 |
+
}).join('');
|
| 576 |
+
|
| 577 |
+
picker.querySelectorAll('.session-chip').forEach(chip => {
|
| 578 |
+
chip.addEventListener('click', () => {
|
| 579 |
+
const sid = chip.dataset.sid;
|
| 580 |
+
if (selectedSessions.has(sid)) selectedSessions.delete(sid);
|
| 581 |
+
else selectedSessions.add(sid);
|
| 582 |
+
chip.classList.toggle('active');
|
| 583 |
+
localStorage.setItem('selectedSessions', JSON.stringify([...selectedSessions]));
|
| 584 |
+
renderPayments();
|
| 585 |
+
});
|
| 586 |
+
});
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
function buildPayHeader() {
|
| 590 |
+
const hr = $('#payHeaderRow'), fr = $('#payFilterRow');
|
| 591 |
+
hr.innerHTML = PAY.columns.map((c, i) =>
|
| 592 |
+
`<th data-col="${i}">${c}<span class="arrow"></span></th>`
|
| 593 |
+
).join('');
|
| 594 |
+
fr.innerHTML = PAY.columns.map((_, i) =>
|
| 595 |
+
`<th><input type="text" data-col="${i}" placeholder="filter"></th>`
|
| 596 |
+
).join('');
|
| 597 |
+
hr.querySelectorAll('th').forEach(th =>
|
| 598 |
+
th.addEventListener('click', () => { const ci = +th.dataset.col; if (paySortCol === ci) paySortAsc = !paySortAsc; else { paySortCol = ci; paySortAsc = true; } renderPayments(); })
|
| 599 |
+
);
|
| 600 |
+
fr.querySelectorAll('input').forEach(inp =>
|
| 601 |
+
inp.addEventListener('input', () => { payColFilters[+inp.dataset.col] = inp.value.toLowerCase(); renderPayments(); })
|
| 602 |
+
);
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
function renderPayments() {
|
| 606 |
+
const q = $('#paySearch').value.toLowerCase();
|
| 607 |
+
const sidIdx = PAY.columns.indexOf('participant_session_id');
|
| 608 |
+
let rows = PAY.rows;
|
| 609 |
+
|
| 610 |
+
if (selectedSessions.size > 0 && sidIdx !== -1) {
|
| 611 |
+
rows = rows.filter(r => selectedSessions.has(String(r[sidIdx])));
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
rows = rows.filter(r => payColFilters.every((f, i) => !f || String(r[i]).toLowerCase().includes(f)));
|
| 615 |
+
if (q) rows = rows.filter(r => r.some(v => String(v).toLowerCase().includes(q)));
|
| 616 |
+
|
| 617 |
+
if (paySortCol >= 0) {
|
| 618 |
+
rows = [...rows].sort((a, b) => {
|
| 619 |
+
let va = a[paySortCol], vb = b[paySortCol];
|
| 620 |
+
if (va === '' && vb === '') return 0; if (va === '') return 1; if (vb === '') return -1;
|
| 621 |
+
const na = parseFloat(va), nb = parseFloat(vb);
|
| 622 |
+
if (!isNaN(na) && !isNaN(nb)) return paySortAsc ? na - nb : nb - na;
|
| 623 |
+
return paySortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
| 624 |
+
});
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
$('#payHeaderRow').querySelectorAll('th').forEach(th => {
|
| 628 |
+
const ci = +th.dataset.col;
|
| 629 |
+
th.querySelector('.arrow').textContent = ci === paySortCol ? (paySortAsc ? '\u25B2' : '\u25BC') : '';
|
| 630 |
+
});
|
| 631 |
+
|
| 632 |
+
$('#payTbody').innerHTML = rows.map(r =>
|
| 633 |
+
`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`
|
| 634 |
+
).join('');
|
| 635 |
+
|
| 636 |
+
$('#payRowCount').textContent = `${rows.length} / ${PAY.rows.length} rows`;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
$('#paySearch').addEventListener('input', renderPayments);
|
| 640 |
+
$('#payRefreshBtn').addEventListener('click', loadPayments);
|
| 641 |
+
|
| 642 |
+
// ===================== CAROUSEL DELIVERY MODE =====================
|
| 643 |
+
let carouselActive = false;
|
| 644 |
+
let carouselIdx = 0;
|
| 645 |
+
let carouselRows = []; // sorted rows for delivery
|
| 646 |
+
let paidSet = new Set(); // keys: "session::pcid"
|
| 647 |
+
|
| 648 |
+
function paidKey(row) {
|
| 649 |
+
const pcIdx = PAY.columns.indexOf('PC_id');
|
| 650 |
+
const sidIdx = PAY.columns.indexOf('participant_session_id');
|
| 651 |
+
return `${row[sidIdx]}::${row[pcIdx]}`;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
function loadPaidState() {
|
| 655 |
+
try {
|
| 656 |
+
const saved = localStorage.getItem('paidState');
|
| 657 |
+
if (saved) paidSet = new Set(JSON.parse(saved));
|
| 658 |
+
} catch(e) {}
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
function savePaidState() {
|
| 662 |
+
localStorage.setItem('paidState', JSON.stringify([...paidSet]));
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
function toggleCarousel() {
|
| 666 |
+
carouselActive = !carouselActive;
|
| 667 |
+
const tableWrap = document.querySelector('#page-payments .table-wrap');
|
| 668 |
+
const sessionPicker = $('#sessionPicker');
|
| 669 |
+
const searchInput = $('#paySearch');
|
| 670 |
+
const wrap = $('#carouselWrap');
|
| 671 |
+
const btn = $('#carouselToggle');
|
| 672 |
+
|
| 673 |
+
if (carouselActive) {
|
| 674 |
+
tableWrap.style.display = 'none';
|
| 675 |
+
sessionPicker.style.display = 'none';
|
| 676 |
+
searchInput.style.display = 'none';
|
| 677 |
+
wrap.classList.add('active');
|
| 678 |
+
btn.textContent = 'Table Mode';
|
| 679 |
+
btn.classList.remove('btn-green');
|
| 680 |
+
btn.classList.add('btn-dim');
|
| 681 |
+
localStorage.setItem('deliveryMode', '1');
|
| 682 |
+
buildCarousel();
|
| 683 |
+
} else {
|
| 684 |
+
tableWrap.style.display = '';
|
| 685 |
+
sessionPicker.style.display = '';
|
| 686 |
+
searchInput.style.display = '';
|
| 687 |
+
wrap.classList.remove('active');
|
| 688 |
+
btn.textContent = 'Delivery Mode';
|
| 689 |
+
btn.classList.remove('btn-dim');
|
| 690 |
+
btn.classList.add('btn-green');
|
| 691 |
+
localStorage.setItem('deliveryMode', '0');
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function buildCarousel() {
|
| 696 |
+
const pcIdx = PAY.columns.indexOf('PC_id');
|
| 697 |
+
const sidIdx = PAY.columns.indexOf('participant_session_id');
|
| 698 |
+
const bonusIdx = PAY.columns.indexOf('total_bonus');
|
| 699 |
+
|
| 700 |
+
// Filter by selected sessions
|
| 701 |
+
let rows = PAY.rows;
|
| 702 |
+
if (selectedSessions.size > 0 && sidIdx !== -1) {
|
| 703 |
+
rows = rows.filter(r => selectedSessions.has(String(r[sidIdx])));
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
// Sort by PC_id numerically
|
| 707 |
+
carouselRows = [...rows].sort((a, b) => {
|
| 708 |
+
const va = parseFloat(a[pcIdx]) || 0;
|
| 709 |
+
const vb = parseFloat(b[pcIdx]) || 0;
|
| 710 |
+
return va - vb;
|
| 711 |
+
});
|
| 712 |
+
|
| 713 |
+
// Skip to first unpaid
|
| 714 |
+
carouselIdx = 0;
|
| 715 |
+
for (let i = 0; i < carouselRows.length; i++) {
|
| 716 |
+
if (!paidSet.has(paidKey(carouselRows[i]))) { carouselIdx = i; break; }
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
renderCarousel();
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
function renderCarousel() {
|
| 723 |
+
const pcIdx = PAY.columns.indexOf('PC_id');
|
| 724 |
+
const bonusIdx = PAY.columns.indexOf('total_bonus');
|
| 725 |
+
const sidIdx = PAY.columns.indexOf('participant_session_id');
|
| 726 |
+
const track = $('#carouselTrack');
|
| 727 |
+
const n = carouselRows.length;
|
| 728 |
+
|
| 729 |
+
if (n === 0) {
|
| 730 |
+
track.innerHTML = '<div style="color:var(--dim)">No participants in selected session(s)</div>';
|
| 731 |
+
return;
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
// Clamp index
|
| 735 |
+
if (carouselIdx < 0) carouselIdx = 0;
|
| 736 |
+
if (carouselIdx >= n) carouselIdx = n - 1;
|
| 737 |
+
|
| 738 |
+
function makeCard(row, type) {
|
| 739 |
+
if (!row) return `<div class="carousel-card phantom" style="visibility:hidden"></div>`;
|
| 740 |
+
const pc = row[pcIdx] || '?';
|
| 741 |
+
const amount = row[bonusIdx] !== '' ? `$${parseFloat(row[bonusIdx]).toFixed(2)}` : '$—';
|
| 742 |
+
const session = row[sidIdx] || '';
|
| 743 |
+
const isPaid = paidSet.has(paidKey(row));
|
| 744 |
+
const paidClass = isPaid ? ' paid' : '';
|
| 745 |
+
return `<div class="carousel-card ${type}${paidClass}">
|
| 746 |
+
<div class="cc-label">PC Station</div>
|
| 747 |
+
<div class="cc-pc">${pc}</div>
|
| 748 |
+
<div class="cc-label">Payment</div>
|
| 749 |
+
<div class="cc-amount">${amount}</div>
|
| 750 |
+
<div class="cc-session">Session ${session}</div>
|
| 751 |
+
${isPaid ? '<div class="cc-paid-badge">Paid</div>' : ''}
|
| 752 |
+
</div>`;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
const prev = carouselIdx > 0 ? carouselRows[carouselIdx - 1] : null;
|
| 756 |
+
const curr = carouselRows[carouselIdx];
|
| 757 |
+
const next = carouselIdx < n - 1 ? carouselRows[carouselIdx + 1] : null;
|
| 758 |
+
|
| 759 |
+
track.innerHTML = `<div class="carousel-inner">${makeCard(prev, 'phantom') + makeCard(curr, 'focus') + makeCard(next, 'phantom')}</div>`;
|
| 760 |
+
|
| 761 |
+
// Update paid button
|
| 762 |
+
const isPaid = paidSet.has(paidKey(curr));
|
| 763 |
+
const paidBtn = $('#carPaidBtn');
|
| 764 |
+
paidBtn.textContent = isPaid ? 'Undo Paid' : 'Mark Paid';
|
| 765 |
+
paidBtn.classList.toggle('is-paid', isPaid);
|
| 766 |
+
|
| 767 |
+
// Update progress
|
| 768 |
+
const paidCount = carouselRows.filter(r => paidSet.has(paidKey(r))).length;
|
| 769 |
+
$('#carouselCount').textContent = `${paidCount} / ${n} paid`;
|
| 770 |
+
$('#carouselProgFill').style.width = `${n ? (paidCount / n * 100) : 0}%`;
|
| 771 |
+
const sessions = [...new Set(carouselRows.map(r => r[sidIdx]))].filter(Boolean);
|
| 772 |
+
$('#carouselSession').textContent = sessions.length ? `Session ${sessions.join(', ')}` : '';
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
function carouselMarkPaid() {
|
| 776 |
+
if (carouselRows.length === 0 || carouselAnimating) return;
|
| 777 |
+
const key = paidKey(carouselRows[carouselIdx]);
|
| 778 |
+
if (paidSet.has(key)) {
|
| 779 |
+
paidSet.delete(key);
|
| 780 |
+
savePaidState();
|
| 781 |
+
renderCarousel();
|
| 782 |
+
} else {
|
| 783 |
+
paidSet.add(key);
|
| 784 |
+
savePaidState();
|
| 785 |
+
// Auto-advance to next unpaid
|
| 786 |
+
let nextIdx = -1;
|
| 787 |
+
for (let i = carouselIdx + 1; i < carouselRows.length; i++) {
|
| 788 |
+
if (!paidSet.has(paidKey(carouselRows[i]))) { nextIdx = i; break; }
|
| 789 |
+
}
|
| 790 |
+
if (nextIdx >= 0) {
|
| 791 |
+
renderCarousel(); // update paid badge on current
|
| 792 |
+
setTimeout(() => slideCarousel('left', nextIdx), 200);
|
| 793 |
+
} else {
|
| 794 |
+
renderCarousel();
|
| 795 |
+
}
|
| 796 |
+
}
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
let carouselAnimating = false;
|
| 800 |
+
|
| 801 |
+
function slideCarousel(direction, newIdx) {
|
| 802 |
+
if (carouselAnimating) return;
|
| 803 |
+
const n = carouselRows.length;
|
| 804 |
+
if (newIdx < 0 || newIdx >= n) return;
|
| 805 |
+
if (newIdx === carouselIdx) return;
|
| 806 |
+
carouselAnimating = true;
|
| 807 |
+
const inner = $('#carouselTrack').querySelector('.carousel-inner');
|
| 808 |
+
if (!inner) { carouselIdx = newIdx; renderCarousel(); carouselAnimating = false; return; }
|
| 809 |
+
const shift = direction === 'left' ? '-110%' : '110%';
|
| 810 |
+
inner.style.transform = `translateX(${shift})`;
|
| 811 |
+
inner.addEventListener('transitionend', function handler() {
|
| 812 |
+
inner.removeEventListener('transitionend', handler);
|
| 813 |
+
carouselIdx = newIdx;
|
| 814 |
+
renderCarousel();
|
| 815 |
+
const newInner = $('#carouselTrack').querySelector('.carousel-inner');
|
| 816 |
+
// Start off-screen on the opposite side, no transition
|
| 817 |
+
newInner.style.transition = 'none';
|
| 818 |
+
newInner.style.transform = `translateX(${direction === 'left' ? '110%' : '-110%'})`;
|
| 819 |
+
requestAnimationFrame(() => {
|
| 820 |
+
requestAnimationFrame(() => {
|
| 821 |
+
// Slide in
|
| 822 |
+
newInner.style.transition = '';
|
| 823 |
+
newInner.style.transform = 'translateX(0)';
|
| 824 |
+
newInner.addEventListener('transitionend', function done() {
|
| 825 |
+
newInner.removeEventListener('transitionend', done);
|
| 826 |
+
carouselAnimating = false;
|
| 827 |
+
});
|
| 828 |
+
});
|
| 829 |
+
});
|
| 830 |
+
});
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
$('#carouselToggle').addEventListener('click', toggleCarousel);
|
| 834 |
+
$('#carPrev').addEventListener('click', () => slideCarousel('right', carouselIdx - 1));
|
| 835 |
+
$('#carNext').addEventListener('click', () => slideCarousel('left', carouselIdx + 1));
|
| 836 |
+
$('#carPaidBtn').addEventListener('click', carouselMarkPaid);
|
| 837 |
+
|
| 838 |
+
document.addEventListener('keydown', (e) => {
|
| 839 |
+
if (!carouselActive) return;
|
| 840 |
+
if (e.key === 'ArrowLeft') { e.preventDefault(); slideCarousel('right', carouselIdx - 1); }
|
| 841 |
+
else if (e.key === 'ArrowRight') { e.preventDefault(); slideCarousel('left', carouselIdx + 1); }
|
| 842 |
+
else if (e.key === ' ') { e.preventDefault(); carouselMarkPaid(); }
|
| 843 |
+
else if (e.key === 'Escape') { toggleCarousel(); }
|
| 844 |
+
});
|
| 845 |
+
|
| 846 |
+
loadPaidState();
|
| 847 |
+
|
| 848 |
+
// ===================== PAGE 3: SIGNAL GAME =====================
|
| 849 |
+
let signalLoaded = false;
|
| 850 |
+
|
| 851 |
+
const COLOR_CSS = { Blue: 'color-blue', Red: 'color-red', Yellow: 'color-yellow', Green: 'color-green' };
|
| 852 |
+
|
| 853 |
+
function hbar(label, value, max, cssClass, suffix) {
|
| 854 |
+
const pct = max ? (value / max * 100).toFixed(1) : 0;
|
| 855 |
+
return `<div class="hbar">
|
| 856 |
+
<div class="hbar-label">${label}</div>
|
| 857 |
+
<div class="hbar-track"><div class="hbar-fill ${cssClass}" style="width:${pct}%">${value}</div></div>
|
| 858 |
+
<div class="hbar-val">${suffix || ''}</div>
|
| 859 |
+
</div>`;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
function pctRing(pct, cls) {
|
| 863 |
+
return `<div class="pct-ring ${cls}" style="--pct:${pct}%"><div class="pct-inner">${pct}%</div></div>`;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
async function loadSignal() {
|
| 867 |
+
const res = await fetch('/api/signal');
|
| 868 |
+
const S = await res.json();
|
| 869 |
+
signalLoaded = true;
|
| 870 |
+
|
| 871 |
+
const maxTreat = Math.max(...Object.values(S.treat_dist));
|
| 872 |
+
const maxColor = Math.max(...Object.values(S.overall_colors));
|
| 873 |
+
let html = '';
|
| 874 |
+
|
| 875 |
+
// ---- KPI row ----
|
| 876 |
+
html += `<div class="kpi-row">
|
| 877 |
+
<div class="kpi accent"><div class="kpi-val">${S.total_completed}</div><div class="kpi-label">Completed participants</div></div>
|
| 878 |
+
<div class="kpi green"><div class="kpi-val">${S.total_played}</div><div class="kpi-label">Played signal game</div></div>
|
| 879 |
+
<div class="kpi orange"><div class="kpi-val">${S.total_skipped}</div><div class="kpi-label">Skipped</div></div>
|
| 880 |
+
<div class="kpi purple"><div class="kpi-val">${S.overall_buy_pct}%</div><div class="kpi-label">Bought signal (overall)</div></div>
|
| 881 |
+
<div class="kpi"><div class="kpi-val">${S.total_bought} / ${S.total_played}</div><div class="kpi-label">Buyers / players</div></div>
|
| 882 |
+
</div>`;
|
| 883 |
+
|
| 884 |
+
// ---- Treatment distribution ----
|
| 885 |
+
html += `<div class="donut-row">`;
|
| 886 |
+
|
| 887 |
+
html += `<div class="donut-card"><h4>Treatment distribution</h4>`;
|
| 888 |
+
for (const [t, n] of Object.entries(S.treat_dist).sort()) {
|
| 889 |
+
const cls = t.includes('Control') ? 'color-ctrl' : 'color-treat';
|
| 890 |
+
html += hbar(t, n, maxTreat, cls, `${(n / S.total_played * 100).toFixed(0)}%`);
|
| 891 |
+
}
|
| 892 |
+
html += `</div>`;
|
| 893 |
+
|
| 894 |
+
// ---- Buy rate by treatment ----
|
| 895 |
+
html += `<div class="donut-card"><h4>Buy rate by treatment</h4>`;
|
| 896 |
+
for (const [t, d] of Object.entries(S.buy_by_treat).sort()) {
|
| 897 |
+
const cls = t.includes('Control') ? 'color-ctrl' : 'color-treat';
|
| 898 |
+
html += `<div class="hbar">
|
| 899 |
+
<div class="hbar-label">${t}</div>
|
| 900 |
+
<div class="hbar-track"><div class="hbar-fill ${cls}" style="width:${d.pct}%">${d.bought}/${d.n}</div></div>
|
| 901 |
+
<div class="hbar-val">${d.pct}%</div>
|
| 902 |
+
</div>`;
|
| 903 |
+
}
|
| 904 |
+
html += `</div>`;
|
| 905 |
+
|
| 906 |
+
// ---- Group success by treatment ----
|
| 907 |
+
html += `<div class="donut-card"><h4>Group success by treatment</h4>`;
|
| 908 |
+
for (const [t, d] of Object.entries(S.success_by_treat).sort()) {
|
| 909 |
+
const cls = t.includes('Control') ? 'color-ctrl' : 'color-treat';
|
| 910 |
+
html += `<div class="hbar">
|
| 911 |
+
<div class="hbar-label">${t}</div>
|
| 912 |
+
<div class="hbar-track"><div class="hbar-fill ${cls}" style="width:${d.pct}%">${d.success}/${d.n}</div></div>
|
| 913 |
+
<div class="hbar-val">${d.pct}%</div>
|
| 914 |
+
</div>`;
|
| 915 |
+
}
|
| 916 |
+
html += `</div>`;
|
| 917 |
+
|
| 918 |
+
html += `</div>`; // close donut-row
|
| 919 |
+
|
| 920 |
+
// ---- Color distribution overall ----
|
| 921 |
+
html += `<div class="donut-row">`;
|
| 922 |
+
html += `<div class="donut-card"><h4>Color choice (overall)</h4>`;
|
| 923 |
+
for (const [c, n] of Object.entries(S.overall_colors).sort((a,b) => b[1] - a[1])) {
|
| 924 |
+
html += hbar(c, n, maxColor, COLOR_CSS[c] || 'color-unknown', `${(n / S.total_played * 100).toFixed(0)}%`);
|
| 925 |
+
}
|
| 926 |
+
html += `</div>`;
|
| 927 |
+
|
| 928 |
+
// ---- Color by treatment ----
|
| 929 |
+
for (const [t, colors] of Object.entries(S.color_by_treat).sort()) {
|
| 930 |
+
const maxC = Math.max(...Object.values(colors));
|
| 931 |
+
const total = Object.values(colors).reduce((a,b) => a+b, 0);
|
| 932 |
+
html += `<div class="donut-card"><h4>Colors — ${t}</h4>`;
|
| 933 |
+
for (const [c, n] of Object.entries(colors).sort((a,b) => b[1] - a[1])) {
|
| 934 |
+
html += hbar(c, n, maxC, COLOR_CSS[c] || 'color-unknown', `${(n / total * 100).toFixed(0)}%`);
|
| 935 |
+
}
|
| 936 |
+
html += `</div>`;
|
| 937 |
+
}
|
| 938 |
+
html += `</div>`;
|
| 939 |
+
|
| 940 |
+
// ---- Intent ----
|
| 941 |
+
html += `<div class="donut-row">`;
|
| 942 |
+
html += `<div class="donut-card"><h4>Intended to buy (pre-decision)</h4>`;
|
| 943 |
+
const intentTotal = S.intend_yes + S.intend_no;
|
| 944 |
+
if (intentTotal) {
|
| 945 |
+
html += hbar('Yes', S.intend_yes, intentTotal, 'color-green', `${(S.intend_yes/intentTotal*100).toFixed(0)}%`);
|
| 946 |
+
html += hbar('No', S.intend_no, intentTotal, 'color-red', `${(S.intend_no/intentTotal*100).toFixed(0)}%`);
|
| 947 |
+
} else {
|
| 948 |
+
html += `<div style="color:var(--dim);font-size:12px">No intent data</div>`;
|
| 949 |
+
}
|
| 950 |
+
html += `</div>`;
|
| 951 |
+
|
| 952 |
+
// ---- Success by intent count (treatment only) ----
|
| 953 |
+
html += `<div class="donut-card"><h4>Success rate by group intent count (Treatment)</h4>`;
|
| 954 |
+
const sbi = S.success_by_intend || {};
|
| 955 |
+
const sbiKeys = Object.keys(sbi).sort((a,b) => +a - +b);
|
| 956 |
+
if (sbiKeys.length) {
|
| 957 |
+
for (const k of sbiKeys) {
|
| 958 |
+
const d = sbi[k];
|
| 959 |
+
html += hbar(`${k} intend`, d.pct, 100, 'color-green', `${d.pct}% (${d.success}/${d.n_groups})`);
|
| 960 |
+
}
|
| 961 |
+
} else {
|
| 962 |
+
html += `<div style="color:var(--dim);font-size:12px">No data</div>`;
|
| 963 |
+
}
|
| 964 |
+
html += `</div>`;
|
| 965 |
+
|
| 966 |
+
html += `</div>`; // close first donut-row
|
| 967 |
+
|
| 968 |
+
// ---- Second row ----
|
| 969 |
+
html += `<div class="donut-row">`;
|
| 970 |
+
|
| 971 |
+
// ---- Buy rate by others' intent (treatment only) ----
|
| 972 |
+
html += `<div class="donut-card"><h4>Buy rate by others' intent count (Treatment)</h4>`;
|
| 973 |
+
const boi = S.buy_by_others_intend || {};
|
| 974 |
+
const boiKeys = Object.keys(boi).sort((a,b) => +a - +b);
|
| 975 |
+
if (boiKeys.length) {
|
| 976 |
+
for (const k of boiKeys) {
|
| 977 |
+
const d = boi[k];
|
| 978 |
+
html += hbar(`${k} others`, d.pct, 100, 'color-treat', `${d.pct}% (${d.bought}/${d.n})`);
|
| 979 |
+
}
|
| 980 |
+
} else {
|
| 981 |
+
html += `<div style="color:var(--dim);font-size:12px">No data</div>`;
|
| 982 |
+
}
|
| 983 |
+
html += `</div>`;
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
// ---- Buy rate by group intend count (treatment only) ----
|
| 987 |
+
html += `<div class="donut-card"><h4>Buy rate by group intent count (Treatment)</h4>`;
|
| 988 |
+
const bgi = S.buy_by_group_intend || {};
|
| 989 |
+
const bgiKeys = Object.keys(bgi).sort((a,b) => +a - +b);
|
| 990 |
+
if (bgiKeys.length) {
|
| 991 |
+
for (const k of bgiKeys) {
|
| 992 |
+
const d = bgi[k];
|
| 993 |
+
html += hbar(`${k}/7 intend`, d.pct, 100, 'color-treat', `${d.pct}% (${d.bought}/${d.n})`);
|
| 994 |
+
}
|
| 995 |
+
} else {
|
| 996 |
+
html += `<div style="color:var(--dim);font-size:12px">No data</div>`;
|
| 997 |
+
}
|
| 998 |
+
html += `</div>`;
|
| 999 |
+
|
| 1000 |
+
// ---- Beliefs ----
|
| 1001 |
+
html += `<div class="donut-card"><h4>Beliefs truthful (1-6 scale)</h4>`;
|
| 1002 |
+
const maxBelief = Math.max(...Object.values(S.beliefs_dist), 1);
|
| 1003 |
+
for (let i = 0; i <= 6; i++) {
|
| 1004 |
+
const n = S.beliefs_dist[String(i)] || 0;
|
| 1005 |
+
if (n > 0) html += hbar(String(i), n, maxBelief, 'color-ctrl', '');
|
| 1006 |
+
}
|
| 1007 |
+
html += `</div>`;
|
| 1008 |
+
html += `</div>`;
|
| 1009 |
+
|
| 1010 |
+
// ---- Per session table ----
|
| 1011 |
+
html += `<div class="stats-section"><h3>Per-session breakdown</h3>
|
| 1012 |
+
<table class="session-table">
|
| 1013 |
+
<thead><tr><th>Session</th><th>Played</th><th>Bought</th><th>Buy %</th><th>Treatments</th><th>Colors</th></tr></thead><tbody>`;
|
| 1014 |
+
for (const s of S.per_session) {
|
| 1015 |
+
const treats = Object.entries(s.treatments).map(([t,n]) => `<span class="treat-chip">${t} (${n})</span>`).join('');
|
| 1016 |
+
const colors = Object.entries(s.colors).sort((a,b) => b[1]-a[1]).map(([c,n]) =>
|
| 1017 |
+
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c === 'Blue' ? '#4a88e5' : c === 'Red' ? '#e05555' : c === 'Yellow' ? '#d4a830' : c === 'Green' ? '#4caf80' : '#888'};margin-right:2px" title="${c}"></span>${n}`
|
| 1018 |
+
).join(' ');
|
| 1019 |
+
html += `<tr>
|
| 1020 |
+
<td><strong>${s.session_id}</strong></td>
|
| 1021 |
+
<td>${s.n}</td>
|
| 1022 |
+
<td>${s.bought}</td>
|
| 1023 |
+
<td>${s.buy_pct}%</td>
|
| 1024 |
+
<td><div class="treat-chips">${treats}</div></td>
|
| 1025 |
+
<td>${colors}</td>
|
| 1026 |
+
</tr>`;
|
| 1027 |
+
}
|
| 1028 |
+
html += `</tbody></table></div>`;
|
| 1029 |
+
|
| 1030 |
+
$('#sigBody').innerHTML = html;
|
| 1031 |
+
$('#sigRefreshTime').textContent = new Date().toLocaleTimeString();
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
$('#sigRefreshBtn').addEventListener('click', loadSignal);
|
| 1035 |
+
|
| 1036 |
+
// ===================== PAGE 4: PRISONER + STAG =====================
|
| 1037 |
+
let coopLoaded = false;
|
| 1038 |
+
|
| 1039 |
+
function coopBar(coop, defect) {
|
| 1040 |
+
const total = coop + defect;
|
| 1041 |
+
if (!total) return '';
|
| 1042 |
+
const cPct = (coop / total * 100).toFixed(1);
|
| 1043 |
+
const dPct = (defect / total * 100).toFixed(1);
|
| 1044 |
+
return `<div class="coop-bar">
|
| 1045 |
+
<div class="seg coop" style="width:${cPct}%">${coop > 0 ? coop : ''}</div>
|
| 1046 |
+
<div class="seg defect" style="width:${dPct}%">${defect > 0 ? defect : ''}</div>
|
| 1047 |
+
</div>`;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
function renderGameCol(game, label, cssClass, condLabels) {
|
| 1051 |
+
const g = game;
|
| 1052 |
+
let h = `<h3 class="${cssClass}">${label}</h3>`;
|
| 1053 |
+
|
| 1054 |
+
// KPIs
|
| 1055 |
+
h += `<div class="kpi-row" style="margin-bottom:14px">
|
| 1056 |
+
<div class="kpi" style="min-width:80px;padding:10px 14px"><div class="kpi-val" style="font-size:22px">${g.n}</div><div class="kpi-label">Played</div></div>
|
| 1057 |
+
<div class="kpi" style="min-width:80px;padding:10px 14px"><div class="kpi-val" style="font-size:22px">${g.n_humans}</div><div class="kpi-label">Humans</div></div>
|
| 1058 |
+
<div class="kpi" style="min-width:80px;padding:10px 14px"><div class="kpi-val purple" style="font-size:22px">${g.n_bots}</div><div class="kpi-label">Bots</div></div>
|
| 1059 |
+
<div class="kpi ${g.coop_rate > 50 ? 'green' : 'red'}" style="min-width:80px;padding:10px 14px"><div class="kpi-val" style="font-size:22px">${g.coop_rate}%</div><div class="kpi-label">Coop rate</div></div>
|
| 1060 |
+
</div>`;
|
| 1061 |
+
|
| 1062 |
+
// Overall coop bar
|
| 1063 |
+
h += `<div class="mini-legend"><span class="l-coop">Cooperate</span><span class="l-defect">Defect</span></div>`;
|
| 1064 |
+
h += coopBar(g.n_coop, g.n_defect);
|
| 1065 |
+
h += `<div style="font-size:11px;color:var(--dim);margin-bottom:6px">${g.n_coop} cooperate / ${g.n_defect} defect</div>`;
|
| 1066 |
+
|
| 1067 |
+
// By condition
|
| 1068 |
+
h += `<h4>Cooperation by condition</h4>`;
|
| 1069 |
+
const conds = Object.keys(g.coop_by_treat).sort();
|
| 1070 |
+
for (const c of conds) {
|
| 1071 |
+
const d = g.coop_by_treat[c];
|
| 1072 |
+
const lbl = condLabels[c] || c;
|
| 1073 |
+
h += `<div class="cond-row">
|
| 1074 |
+
<div class="cond-label">${lbl}</div>
|
| 1075 |
+
<div class="cond-bar-wrap">${coopBar(d.coop, d.n - d.coop)}</div>
|
| 1076 |
+
<div class="cond-stats">${d.coop}/${d.n} (${d.pct}%)</div>
|
| 1077 |
+
</div>`;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
// Human vs Bot in bot conditions
|
| 1081 |
+
if (Object.keys(g.human_bot_coop).length) {
|
| 1082 |
+
h += `<h4>Human vs Bot (bot conditions)</h4>`;
|
| 1083 |
+
h += `<div class="mini-legend"><span class="l-human">Human</span><span class="l-bot">Bot</span></div>`;
|
| 1084 |
+
for (const c of Object.keys(g.human_bot_coop).sort()) {
|
| 1085 |
+
const d = g.human_bot_coop[c];
|
| 1086 |
+
const lbl = condLabels[c] || c;
|
| 1087 |
+
h += `<div style="font-size:12px;color:var(--dim);margin-bottom:2px;font-weight:600">${lbl}</div>`;
|
| 1088 |
+
h += `<div class="cond-row">
|
| 1089 |
+
<div class="cond-label" style="width:80px;min-width:80px">Human</div>
|
| 1090 |
+
<div class="cond-bar-wrap">${coopBar(d.human_coop, d.human_n - d.human_coop)}</div>
|
| 1091 |
+
<div class="cond-stats">${d.human_coop}/${d.human_n} (${d.human_pct}%)</div>
|
| 1092 |
+
</div>`;
|
| 1093 |
+
h += `<div class="cond-row">
|
| 1094 |
+
<div class="cond-label" style="width:80px;min-width:80px">Bot</div>
|
| 1095 |
+
<div class="cond-bar-wrap">${coopBar(d.bot_coop, d.bot_n - d.bot_coop)}</div>
|
| 1096 |
+
<div class="cond-stats">${d.bot_coop}/${d.bot_n} (${d.bot_pct}%)</div>
|
| 1097 |
+
</div>`;
|
| 1098 |
+
}
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
// Messages
|
| 1102 |
+
if (Object.keys(g.msg_by_treat).length) {
|
| 1103 |
+
h += `<h4>Chat messages by condition</h4>`;
|
| 1104 |
+
for (const c of Object.keys(g.msg_by_treat).sort()) {
|
| 1105 |
+
const d = g.msg_by_treat[c];
|
| 1106 |
+
const lbl = condLabels[c] || c;
|
| 1107 |
+
const pct = d.n ? (d.with_msg / d.n * 100).toFixed(0) : 0;
|
| 1108 |
+
h += `<div class="hbar">
|
| 1109 |
+
<div class="hbar-label" style="width:130px;min-width:130px">${lbl}</div>
|
| 1110 |
+
<div class="hbar-track"><div class="hbar-fill color-ctrl" style="width:${pct}%">${d.with_msg}</div></div>
|
| 1111 |
+
<div class="hbar-val">${d.with_msg}/${d.n}</div>
|
| 1112 |
+
</div>`;
|
| 1113 |
+
}
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
// Comprehension
|
| 1117 |
+
h += `<h4>Comprehension</h4>`;
|
| 1118 |
+
const compTotal = g.comp_passed + g.comp_failed;
|
| 1119 |
+
const compPct = compTotal ? (g.comp_passed / compTotal * 100).toFixed(1) : 0;
|
| 1120 |
+
h += `<div class="hbar">
|
| 1121 |
+
<div class="hbar-label" style="width:80px;min-width:80px">Passed</div>
|
| 1122 |
+
<div class="hbar-track"><div class="hbar-fill color-green" style="width:${compPct}%">${g.comp_passed}</div></div>
|
| 1123 |
+
<div class="hbar-val">${compPct}%</div>
|
| 1124 |
+
</div>`;
|
| 1125 |
+
if (g.comp_failed > 0) {
|
| 1126 |
+
h += `<div style="font-size:11px;color:var(--red);margin-top:2px">${g.comp_failed} failed comprehension</div>`;
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
// Payoff by condition
|
| 1130 |
+
h += `<h4>Mean payoff by condition</h4>`;
|
| 1131 |
+
const maxPay = Math.max(...Object.values(g.payoff_dist).map(d => d.mean || 0), 1);
|
| 1132 |
+
for (const c of conds) {
|
| 1133 |
+
const d = g.payoff_dist[c];
|
| 1134 |
+
const lbl = condLabels[c] || c;
|
| 1135 |
+
const pct = d.mean != null ? (d.mean / 5 * 100).toFixed(0) : 0; // max payoff is 5
|
| 1136 |
+
h += `<div class="hbar">
|
| 1137 |
+
<div class="hbar-label" style="width:130px;min-width:130px">${lbl}</div>
|
| 1138 |
+
<div class="hbar-track"><div class="hbar-fill ${cssClass === 'prisoner' ? 'color-red' : 'color-green'}" style="width:${pct}%">${d.mean != null ? d.mean : '-'}</div></div>
|
| 1139 |
+
<div class="hbar-val"></div>
|
| 1140 |
+
</div>`;
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
return h;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
async function loadCoop() {
|
| 1147 |
+
const res = await fetch('/api/coop-games');
|
| 1148 |
+
const S = await res.json();
|
| 1149 |
+
coopLoaded = true;
|
| 1150 |
+
|
| 1151 |
+
let html = '';
|
| 1152 |
+
|
| 1153 |
+
// ---- Side by side game panels ----
|
| 1154 |
+
html += `<div class="game-cols">`;
|
| 1155 |
+
html += `<div class="game-col">${renderGameCol(S.prisoner, "Prisoner's Dilemma", 'prisoner', S.cond_labels)}</div>`;
|
| 1156 |
+
html += `<div class="game-col">${renderGameCol(S.stag, "Stag Hunt", 'stag', S.cond_labels)}</div>`;
|
| 1157 |
+
html += `</div>`;
|
| 1158 |
+
|
| 1159 |
+
// ---- Per-session comparison table ----
|
| 1160 |
+
html += `<div class="stats-section"><h3>Per-session cooperation rates</h3>`;
|
| 1161 |
+
html += `<table class="session-table"><thead><tr>
|
| 1162 |
+
<th>Session</th>
|
| 1163 |
+
<th>PD Played</th><th>PD Coop %</th>
|
| 1164 |
+
<th>SH Played</th><th>SH Coop %</th>
|
| 1165 |
+
<th>PD by condition</th><th>SH by condition</th>
|
| 1166 |
+
</tr></thead><tbody>`;
|
| 1167 |
+
|
| 1168 |
+
const allSids = new Set([
|
| 1169 |
+
...S.prisoner.per_session.map(s => s.session_id),
|
| 1170 |
+
...S.stag.per_session.map(s => s.session_id),
|
| 1171 |
+
]);
|
| 1172 |
+
const pdMap = Object.fromEntries(S.prisoner.per_session.map(s => [s.session_id, s]));
|
| 1173 |
+
const shMap = Object.fromEntries(S.stag.per_session.map(s => [s.session_id, s]));
|
| 1174 |
+
|
| 1175 |
+
for (const sid of [...allSids].sort()) {
|
| 1176 |
+
const pd = pdMap[sid] || { n: 0, coop_pct: 0, treatments: {} };
|
| 1177 |
+
const sh = shMap[sid] || { n: 0, coop_pct: 0, treatments: {} };
|
| 1178 |
+
const pdTreats = Object.entries(pd.treatments).sort().map(([c, d]) =>
|
| 1179 |
+
`<span class="treat-chip">${S.cond_labels[c] || c}: ${d.pct}%</span>`
|
| 1180 |
+
).join(' ');
|
| 1181 |
+
const shTreats = Object.entries(sh.treatments).sort().map(([c, d]) =>
|
| 1182 |
+
`<span class="treat-chip">${S.cond_labels[c] || c}: ${d.pct}%</span>`
|
| 1183 |
+
).join(' ');
|
| 1184 |
+
|
| 1185 |
+
const pdColor = pd.coop_pct >= 50 ? 'var(--green)' : 'var(--red)';
|
| 1186 |
+
const shColor = sh.coop_pct >= 50 ? 'var(--green)' : 'var(--red)';
|
| 1187 |
+
|
| 1188 |
+
html += `<tr>
|
| 1189 |
+
<td><strong>${sid}</strong></td>
|
| 1190 |
+
<td>${pd.n}</td>
|
| 1191 |
+
<td style="color:${pdColor};font-weight:600">${pd.coop_pct}%</td>
|
| 1192 |
+
<td>${sh.n}</td>
|
| 1193 |
+
<td style="color:${shColor};font-weight:600">${sh.coop_pct}%</td>
|
| 1194 |
+
<td><div class="treat-chips">${pdTreats}</div></td>
|
| 1195 |
+
<td><div class="treat-chips">${shTreats}</div></td>
|
| 1196 |
+
</tr>`;
|
| 1197 |
+
}
|
| 1198 |
+
html += `</tbody></table></div>`;
|
| 1199 |
+
|
| 1200 |
+
$('#coopBody').innerHTML = html;
|
| 1201 |
+
$('#coopRefreshTime').textContent = new Date().toLocaleTimeString();
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
$('#coopRefreshBtn').addEventListener('click', loadCoop);
|
| 1205 |
+
|
| 1206 |
+
// ===================== INIT =====================
|
| 1207 |
+
loadData();
|
| 1208 |
+
</script>
|
| 1209 |
+
</body>
|
| 1210 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Login - oTree CSV Viewer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a; --text: #e0e0e0; --dim: #888; --accent: #6c9bff; --accent2: #4a7adf; --red: #e05555; }
|
| 9 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 10 |
+
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); display: flex; align-items: center; justify-content: center; height: 100vh; }
|
| 11 |
+
.login-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 40px 36px; width: 360px; text-align: center; }
|
| 12 |
+
.login-card h1 { font-size: 18px; font-weight: 700; color: var(--accent); margin-bottom: 6px; }
|
| 13 |
+
.login-card p { font-size: 13px; color: var(--dim); margin-bottom: 24px; }
|
| 14 |
+
.login-card input[type="password"] { width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; margin-bottom: 16px; outline: none; }
|
| 15 |
+
.login-card input[type="password"]:focus { border-color: var(--accent); }
|
| 16 |
+
.login-card button { width: 100%; padding: 10px; background: var(--accent2); color: #fff; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
| 17 |
+
.login-card button:hover { background: var(--accent); }
|
| 18 |
+
.error { color: var(--red); font-size: 13px; margin-bottom: 12px; }
|
| 19 |
+
</style>
|
| 20 |
+
</head>
|
| 21 |
+
<body>
|
| 22 |
+
<form class="login-card" method="POST" action="/login">
|
| 23 |
+
<h1>oTree CSV Viewer</h1>
|
| 24 |
+
<p>Enter the admin password to continue</p>
|
| 25 |
+
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
| 26 |
+
<input type="password" name="password" placeholder="Password" autofocus>
|
| 27 |
+
<button type="submit">Sign In</button>
|
| 28 |
+
</form>
|
| 29 |
+
</body>
|
| 30 |
+
</html>
|