iurbinah commited on
Commit
8cba00b
·
verified ·
1 Parent(s): 63167cb

Upload folder using huggingface_hub

Browse files
Files changed (7) hide show
  1. .env +2 -0
  2. Dockerfile +12 -0
  3. all_apps_wide-2026-03-31.csv +0 -0
  4. app.py +582 -0
  5. requirements.txt +6 -0
  6. templates/index.html +1210 -0
  7. 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">&#9671;</span> Session Stats</a>
199
+ <a class="active" data-page="results"><span class="icon">&#9638;</span> Collect Results</a>
200
+ <a data-page="signal"><span class="icon">&#9830;</span> Signal Game</a>
201
+ <a data-page="coop"><span class="icon">&#9876;</span> Prisoner + Stag</a>
202
+ <a data-page="payments"><span class="icon">&#36;</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">&#9664; Prev</button>
311
+ <button class="btn btn-paid" id="carPaidBtn">Mark Paid</button>
312
+ <button class="btn btn-dim" id="carNext">Next &#9654;</button>
313
+ </div>
314
+ <div class="carousel-keys">Arrow keys to navigate &middot; Space to mark paid &middot; 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">&#9654;</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(' &nbsp;');
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>