Show sourcecode
The following files exists in this folder. Click to view.
admin.php
admin_login.php
api/
config.php
css/
db_setup.php
index.php
js/
results.php
tests/
admin.php
751 lines UTF-8 Unix (LF)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
<?php
session_start();
if (empty($_SESSION['admin_logged_in'])) {
header('Location: admin_login.php');
exit;
}
require __DIR__ . '/config.php';
$pdo = getDB();
// --- Fetch all data ---
// Participants
$participants = $pdo->query("
SELECT p.id, p.email, p.group_type, p.created_at, p.completed_at
FROM participants p
ORDER BY p.created_at DESC
")->fetchAll();
// Reaction times per participant (all rounds)
$reactions = $pdo->query("
SELECT r.participant_id, p.group_type, r.round_number, r.reaction_ms, r.was_premature, r.delay_ms
FROM reaction_times r
JOIN participants p ON p.id = r.participant_id
ORDER BY r.participant_id, r.round_number
")->fetchAll();
// Maze results
$mazes = $pdo->query("
SELECT m.participant_id, p.group_type, m.total_time_ms, m.total_moves
FROM maze_results m
JOIN participants p ON p.id = m.participant_id
ORDER BY m.participant_id
")->fetchAll();
// Simon results
$simons = $pdo->query("
SELECT s.participant_id, p.group_type, s.max_level, s.total_time_ms
FROM simon_results s
JOIN participants p ON p.id = s.participant_id
ORDER BY s.participant_id
")->fetchAll();
// Simon rounds (per-level detail)
$simonRounds = $pdo->query("
SELECT sr.participant_id, sr.level_number, sr.success, sr.response_times_json
FROM simon_rounds sr
ORDER BY sr.participant_id, sr.level_number
")->fetchAll();
// --- Build per-participant data structure ---
$participantData = [];
foreach ($participants as $p) {
$participantData[$p['id']] = [
'email' => $p['email'],
'group' => $p['group_type'],
'created_at' => $p['created_at'],
'completed_at' => $p['completed_at'],
'reaction_rounds' => [],
'reaction_avg' => null,
'reaction_best' => null,
'maze' => null,
'simon' => null,
'simon_rounds' => [],
];
}
foreach ($reactions as $r) {
$pid = $r['participant_id'];
if (!isset($participantData[$pid])) continue;
$participantData[$pid]['reaction_rounds'][] = $r;
}
// Compute per-participant reaction averages
foreach ($participantData as $pid => &$pd) {
$valid = array_filter($pd['reaction_rounds'], fn($r) => !$r['was_premature']);
if (count($valid) > 0) {
$times = array_column($valid, 'reaction_ms');
$pd['reaction_avg'] = round(array_sum($times) / count($times));
$pd['reaction_best'] = min($times);
}
}
unset($pd);
foreach ($mazes as $m) {
$pid = $m['participant_id'];
if (!isset($participantData[$pid])) continue;
$participantData[$pid]['maze'] = $m;
}
foreach ($simons as $s) {
$pid = $s['participant_id'];
if (!isset($participantData[$pid])) continue;
$participantData[$pid]['simon'] = $s;
}
foreach ($simonRounds as $sr) {
$pid = $sr['participant_id'];
if (!isset($participantData[$pid])) continue;
$participantData[$pid]['simon_rounds'][] = $sr;
}
// Split participants by group
$groups = ['placebo' => [], 'control' => []];
foreach ($participantData as $pid => $pd) {
$groups[$pd['group']][$pid] = $pd;
}
// Build group-level chart data
$reactionByGroup = ['placebo' => [], 'control' => []];
$mazeTimeByGroup = ['placebo' => [], 'control' => []];
$mazeMovesByGroup = ['placebo' => [], 'control' => []];
$simonByGroup = ['placebo' => [], 'control' => []];
foreach ($participantData as $pid => $pd) {
$g = $pd['group'];
if ($pd['reaction_avg'] !== null) {
$reactionByGroup[$g][] = $pd['reaction_avg'];
}
if ($pd['maze']) {
$mazeTimeByGroup[$g][] = round($pd['maze']['total_time_ms'] / 1000, 1);
$mazeMovesByGroup[$g][] = $pd['maze']['total_moves'];
}
if ($pd['simon']) {
$simonByGroup[$g][] = $pd['simon']['max_level'];
}
}
// Compute group averages for summary cards
function groupAvg($arr) {
return count($arr) > 0 ? round(array_sum($arr) / count($arr), 1) : 0;
}
$totalParticipants = count($participants);
$completedCount = count(array_filter($participants, fn($p) => $p['completed_at'] !== null));
// Build per-group reaction leaderboards: sorted ascending (best first = rank #1)
$reactionLeaderboardPlacebo = [];
$reactionLeaderboardControl = [];
foreach ($participantData as $pid => $pd) {
if ($pd['reaction_avg'] === null) continue;
$entry = ['email' => $pd['email'], 'avg' => $pd['reaction_avg']];
if ($pd['group'] === 'placebo') {
$reactionLeaderboardPlacebo[] = $entry;
} else {
$reactionLeaderboardControl[] = $entry;
}
}
usort($reactionLeaderboardPlacebo, fn($a, $b) => $a['avg'] - $b['avg']);
usort($reactionLeaderboardControl, fn($a, $b) => $a['avg'] - $b['avg']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/admin.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
</head>
<body>
<div class="admin-wrapper">
<header class="admin-header">
<h1>Experiment Dashboard</h1>
<div class="admin-header-actions">
<span class="live-indicator" id="live-indicator" title="Auto-refreshing every 10s">LIVE</span>
<a href="api/export_csv.php?table=all" class="btn btn-primary">Export All (CSV)</a>
<a href="admin_login.php?logout=1" class="btn btn-secondary">Log Out</a>
</div>
</header>
<!-- Summary cards -->
<div class="summary-cards">
<div class="card">
<div class="card-label">Total Participants</div>
<div class="card-value" id="card-total"><?= $totalParticipants ?></div>
</div>
<div class="card">
<div class="card-label">Completed</div>
<div class="card-value" id="card-completed"><?= $completedCount ?></div>
</div>
<div class="card">
<div class="card-label">Avg Reaction (Placebo)</div>
<div class="card-value" id="card-reaction-placebo"><?= groupAvg($reactionByGroup['placebo']) ?> ms</div>
</div>
<div class="card">
<div class="card-label">Avg Reaction (Control)</div>
<div class="card-value" id="card-reaction-control"><?= groupAvg($reactionByGroup['control']) ?> ms</div>
</div>
<div class="card">
<div class="card-label">Avg Simon Level (Placebo)</div>
<div class="card-value" id="card-simon-placebo"><?= groupAvg($simonByGroup['placebo']) ?></div>
</div>
<div class="card">
<div class="card-label">Avg Simon Level (Control)</div>
<div class="card-value" id="card-simon-control"><?= groupAvg($simonByGroup['control']) ?></div>
</div>
</div>
<!-- Charts row 1 -->
<div class="chart-row">
<div class="chart-box">
<h2>Avg Reaction Time by Group</h2>
<canvas id="chart-reaction"></canvas>
</div>
<div class="chart-box">
<h2>Reaction Time Leaderboard</h2>
<canvas id="chart-reaction-dist"></canvas>
</div>
</div>
<!-- Charts row 2 -->
<div class="chart-row">
<div class="chart-box">
<h2>Maze Completion Time by Group</h2>
<canvas id="chart-maze-time"></canvas>
</div>
<div class="chart-box">
<h2>Maze Total Moves by Group</h2>
<canvas id="chart-maze-moves"></canvas>
</div>
</div>
<!-- Charts row 3 -->
<div class="chart-row">
<div class="chart-box">
<h2>Simon Level Reached by Group</h2>
<canvas id="chart-simon"></canvas>
</div>
<div class="chart-box">
<h2>Simon Level Distribution</h2>
<canvas id="chart-simon-dist"></canvas>
</div>
</div>
<!-- Participant data grouped by group -->
<div id="participant-sections">
<?php foreach (['placebo' => 'Placebo Group', 'control' => 'Control Group'] as $groupKey => $groupLabel): ?>
<div class="admin-section" data-group="<?= $groupKey ?>">
<div class="group-header">
<h2><span class="badge badge-<?= $groupKey ?>"><?= $groupLabel ?></span> (<?= count($groups[$groupKey]) ?> participants)</h2>
<a href="api/export_csv.php?table=all" class="btn btn-secondary btn-sm">Export CSV</a>
</div>
<?php if (empty($groups[$groupKey])): ?>
<p style="color: #999; padding: 1rem 0;">No participants in this group yet.</p>
<?php endif; ?>
<?php foreach ($groups[$groupKey] as $pid => $pd): ?>
<details class="participant-accordion">
<summary class="participant-summary">
<span class="participant-email"><?= htmlspecialchars($pd['email']) ?></span>
<span class="participant-stats">
<?php if ($pd['reaction_avg'] !== null): ?>
<span class="stat-chip">Reaction: <?= $pd['reaction_avg'] ?> ms</span>
<?php endif; ?>
<?php if ($pd['maze']): ?>
<span class="stat-chip">Maze: <?= round($pd['maze']['total_time_ms'] / 1000, 1) ?>s / <?= $pd['maze']['total_moves'] ?> moves</span>
<?php endif; ?>
<?php if ($pd['simon']): ?>
<span class="stat-chip">Simon: Level <?= $pd['simon']['max_level'] ?></span>
<?php endif; ?>
<?php if (!$pd['completed_at']): ?>
<span class="stat-chip stat-chip-pending">In progress</span>
<?php endif; ?>
</span>
</summary>
<div class="participant-details">
<div class="detail-meta">
ID: <?= $pid ?> | Started: <?= $pd['created_at'] ?> | Completed: <?= $pd['completed_at'] ?? '<em>Not yet</em>' ?>
</div>
<?php if (!empty($pd['reaction_rounds'])): ?>
<div class="detail-block">
<h3>Reaction Time Rounds</h3>
<table>
<thead>
<tr>
<th>Round</th>
<th>Reaction (ms)</th>
<th>Premature</th>
<th>Delay (ms)</th>
</tr>
</thead>
<tbody>
<?php foreach ($pd['reaction_rounds'] as $r): ?>
<tr>
<td><?= $r['round_number'] ?></td>
<td><?= $r['reaction_ms'] ?></td>
<td><?= $r['was_premature'] ? 'Yes' : 'No' ?></td>
<td><?= $r['delay_ms'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td><strong>Avg / Best</strong></td>
<td><strong><?= $pd['reaction_avg'] ?> ms / <?= $pd['reaction_best'] ?> ms</strong></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
<?php if ($pd['maze']): ?>
<div class="detail-block">
<h3>Maze</h3>
<table>
<thead>
<tr><th>Time</th><th>Moves</th></tr>
</thead>
<tbody>
<tr>
<td><?= round($pd['maze']['total_time_ms'] / 1000, 1) ?>s</td>
<td><?= $pd['maze']['total_moves'] ?></td>
</tr>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($pd['simon']): ?>
<div class="detail-block">
<h3>Simon Says — Max Level: <?= $pd['simon']['max_level'] ?></h3>
<?php if (!empty($pd['simon_rounds'])): ?>
<table>
<thead>
<tr>
<th>Level</th>
<th>Result</th>
<th>Response Times (ms)</th>
</tr>
</thead>
<tbody>
<?php foreach ($pd['simon_rounds'] as $sr): ?>
<tr>
<td><?= $sr['level_number'] ?></td>
<td><?= $sr['success'] ? 'Passed' : 'Failed' ?></td>
<td><?= implode(', ', json_decode($sr['response_times_json'], true) ?: []) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</details>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<script>
const COLORS = {
placebo: { bg: 'rgba(67, 97, 238, 0.6)', border: '#4361ee' },
control: { bg: 'rgba(234, 179, 8, 0.6)', border: '#eab308' }
};
// Store chart instances for live updates
var charts = {};
// --- Reaction Time: Group comparison (bar) ---
charts.reaction = new Chart(document.getElementById('chart-reaction'), {
type: 'bar',
data: {
labels: ['Placebo', 'Control'],
datasets: [{
label: 'Avg Reaction Time (ms)',
data: [
<?= groupAvg($reactionByGroup['placebo']) ?>,
<?= groupAvg($reactionByGroup['control']) ?>
],
backgroundColor: [COLORS.placebo.bg, COLORS.control.bg],
borderColor: [COLORS.placebo.border, COLORS.control.border],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'ms' } } }
}
});
// --- Reaction Time: Leaderboard (scatter, per-group ranked best first) ---
var leaderboardPlacebo = <?= json_encode(array_values($reactionLeaderboardPlacebo)) ?>;
var leaderboardControl = <?= json_encode(array_values($reactionLeaderboardControl)) ?>;
(function() {
var placeboData = leaderboardPlacebo.map(function(e, i) { return { x: i + 1, y: e.avg }; });
var controlData = leaderboardControl.map(function(e, i) { return { x: i + 1, y: e.avg }; });
var maxRank = Math.max(leaderboardPlacebo.length, leaderboardControl.length, 1);
charts.reactionDist = new Chart(document.getElementById('chart-reaction-dist'), {
type: 'scatter',
data: {
datasets: [
{
label: 'Placebo',
data: placeboData,
backgroundColor: COLORS.placebo.bg,
borderColor: COLORS.placebo.border,
pointRadius: 7,
pointHoverRadius: 9,
showLine: true,
borderWidth: 2,
fill: false,
tension: 0
},
{
label: 'Control',
data: controlData,
backgroundColor: COLORS.control.bg,
borderColor: COLORS.control.border,
pointRadius: 7,
pointHoverRadius: 9,
showLine: true,
borderWidth: 2,
fill: false,
tension: 0
}
]
},
options: {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: function(ctx) {
var list = ctx.datasetIndex === 0 ? leaderboardPlacebo : leaderboardControl;
var entry = list[ctx.dataIndex];
return entry.email.split('@')[0] + ': ' + entry.avg + ' ms';
}
}
}
},
scales: {
x: {
title: { display: true, text: 'Rank (#1 = best)' },
reverse: true,
min: 1,
max: maxRank,
ticks: { stepSize: 1 }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Avg Reaction (ms)' }
}
}
}
});
})();
// --- Maze Time: Group comparison ---
charts.mazeTime = new Chart(document.getElementById('chart-maze-time'), {
type: 'bar',
data: {
labels: ['Placebo', 'Control'],
datasets: [{
label: 'Avg Maze Time (s)',
data: [
<?= groupAvg($mazeTimeByGroup['placebo']) ?>,
<?= groupAvg($mazeTimeByGroup['control']) ?>
],
backgroundColor: [COLORS.placebo.bg, COLORS.control.bg],
borderColor: [COLORS.placebo.border, COLORS.control.border],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Seconds' } } }
}
});
// --- Maze Moves: Group comparison ---
charts.mazeMoves = new Chart(document.getElementById('chart-maze-moves'), {
type: 'bar',
data: {
labels: ['Placebo', 'Control'],
datasets: [{
label: 'Avg Moves',
data: [
<?= groupAvg($mazeMovesByGroup['placebo']) ?>,
<?= groupAvg($mazeMovesByGroup['control']) ?>
],
backgroundColor: [COLORS.placebo.bg, COLORS.control.bg],
borderColor: [COLORS.placebo.border, COLORS.control.border],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Moves' } } }
}
});
// --- Simon: Group comparison ---
charts.simon = new Chart(document.getElementById('chart-simon'), {
type: 'bar',
data: {
labels: ['Placebo', 'Control'],
datasets: [{
label: 'Avg Level Reached',
data: [
<?= groupAvg($simonByGroup['placebo']) ?>,
<?= groupAvg($simonByGroup['control']) ?>
],
backgroundColor: [COLORS.placebo.bg, COLORS.control.bg],
borderColor: [COLORS.placebo.border, COLORS.control.border],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, title: { display: true, text: 'Level' } } }
}
});
// --- Simon: Level distribution (scatter) ---
charts.simonDist = new Chart(document.getElementById('chart-simon-dist'), {
type: 'scatter',
data: {
datasets: [
{
label: 'Placebo',
data: <?= json_encode(array_map(fn($v, $i) => ['x' => $i + 1, 'y' => $v], $simonByGroup['placebo'], array_keys($simonByGroup['placebo']))) ?>,
backgroundColor: COLORS.placebo.bg,
borderColor: COLORS.placebo.border,
pointRadius: 6
},
{
label: 'Control',
data: <?= json_encode(array_map(fn($v, $i) => ['x' => $i + 1, 'y' => $v], $simonByGroup['control'], array_keys($simonByGroup['control']))) ?>,
backgroundColor: COLORS.control.bg,
borderColor: COLORS.control.border,
pointRadius: 6
}
]
},
options: {
responsive: true,
scales: {
x: { title: { display: true, text: 'Participant #' } },
y: { beginAtZero: true, title: { display: true, text: 'Max Level' } }
}
}
});
// ========== LIVE UPDATE LOGIC ==========
var POLL_INTERVAL = 10000; // 10 seconds
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function groupAvgJs(arr) {
if (!arr || arr.length === 0) return 0;
var sum = arr.reduce(function(a, b) { return a + b; }, 0);
return Math.round((sum / arr.length) * 10) / 10;
}
function buildParticipantHtml(pd) {
var chips = '';
if (pd.reaction_avg !== null) {
chips += '<span class="stat-chip">Reaction: ' + pd.reaction_avg + ' ms</span>';
}
if (pd.maze) {
var mazeTimeSec = (parseInt(pd.maze.total_time_ms) / 1000).toFixed(1);
chips += '<span class="stat-chip">Maze: ' + mazeTimeSec + 's / ' + pd.maze.total_moves + ' moves</span>';
}
if (pd.simon) {
chips += '<span class="stat-chip">Simon: Level ' + pd.simon.max_level + '</span>';
}
if (!pd.completed_at) {
chips += '<span class="stat-chip stat-chip-pending">In progress</span>';
}
var html = '<details class="participant-accordion">';
html += '<summary class="participant-summary">';
html += '<span class="participant-email">' + escapeHtml(pd.email) + '</span>';
html += '<span class="participant-stats">' + chips + '</span>';
html += '</summary>';
html += '<div class="participant-details">';
html += '<div class="detail-meta">ID: ' + pd.id + ' | Started: ' + pd.created_at + ' | Completed: ' + (pd.completed_at || '<em>Not yet</em>') + '</div>';
// Reaction rounds
if (pd.reaction_rounds && pd.reaction_rounds.length > 0) {
html += '<div class="detail-block"><h3>Reaction Time Rounds</h3><table>';
html += '<thead><tr><th>Round</th><th>Reaction (ms)</th><th>Premature</th><th>Delay (ms)</th></tr></thead><tbody>';
pd.reaction_rounds.forEach(function(r) {
html += '<tr><td>' + r.round_number + '</td><td>' + r.reaction_ms + '</td>';
html += '<td>' + (parseInt(r.was_premature) ? 'Yes' : 'No') + '</td><td>' + r.delay_ms + '</td></tr>';
});
html += '</tbody>';
if (pd.reaction_avg !== null) {
html += '<tfoot><tr><td><strong>Avg / Best</strong></td>';
html += '<td><strong>' + pd.reaction_avg + ' ms / ' + pd.reaction_best + ' ms</strong></td>';
html += '<td></td><td></td></tr></tfoot>';
}
html += '</table></div>';
}
// Maze
if (pd.maze) {
var mt = (parseInt(pd.maze.total_time_ms) / 1000).toFixed(1);
html += '<div class="detail-block"><h3>Maze</h3><table>';
html += '<thead><tr><th>Time</th><th>Moves</th></tr></thead>';
html += '<tbody><tr><td>' + mt + 's</td><td>' + pd.maze.total_moves + '</td></tr></tbody></table></div>';
}
// Simon
if (pd.simon) {
html += '<div class="detail-block"><h3>Simon Says — Max Level: ' + pd.simon.max_level + '</h3>';
if (pd.simon_rounds && pd.simon_rounds.length > 0) {
html += '<table><thead><tr><th>Level</th><th>Result</th><th>Response Times (ms)</th></tr></thead><tbody>';
pd.simon_rounds.forEach(function(sr) {
var times = [];
try { times = JSON.parse(sr.response_times_json) || []; } catch(e) {}
html += '<tr><td>' + sr.level_number + '</td>';
html += '<td>' + (parseInt(sr.success) ? 'Passed' : 'Failed') + '</td>';
html += '<td>' + times.join(', ') + '</td></tr>';
});
html += '</tbody></table>';
}
html += '</div>';
}
html += '</div></details>';
return html;
}
function updateDashboard(data) {
// Summary cards
document.getElementById('card-total').textContent = data.summary.total_participants;
document.getElementById('card-completed').textContent = data.summary.completed;
document.getElementById('card-reaction-placebo').textContent = data.summary.reaction_avg_placebo + ' ms';
document.getElementById('card-reaction-control').textContent = data.summary.reaction_avg_control + ' ms';
document.getElementById('card-simon-placebo').textContent = data.summary.simon_avg_placebo;
document.getElementById('card-simon-control').textContent = data.summary.simon_avg_control;
// Reaction bar chart
charts.reaction.data.datasets[0].data = [
data.summary.reaction_avg_placebo,
data.summary.reaction_avg_control
];
charts.reaction.update();
// Reaction leaderboard
leaderboardPlacebo = data.leaderboard_placebo;
leaderboardControl = data.leaderboard_control;
var newPlaceboData = leaderboardPlacebo.map(function(e, i) { return { x: i + 1, y: e.avg }; });
var newControlData = leaderboardControl.map(function(e, i) { return { x: i + 1, y: e.avg }; });
var newMaxRank = Math.max(leaderboardPlacebo.length, leaderboardControl.length, 1);
charts.reactionDist.data.datasets[0].data = newPlaceboData;
charts.reactionDist.data.datasets[1].data = newControlData;
charts.reactionDist.options.scales.x.max = newMaxRank;
charts.reactionDist.update();
// Maze time bar chart
charts.mazeTime.data.datasets[0].data = [
data.summary.maze_time_avg_placebo,
data.summary.maze_time_avg_control
];
charts.mazeTime.update();
// Maze moves bar chart
charts.mazeMoves.data.datasets[0].data = [
data.summary.maze_moves_avg_placebo,
data.summary.maze_moves_avg_control
];
charts.mazeMoves.update();
// Simon bar chart
charts.simon.data.datasets[0].data = [
data.summary.simon_avg_placebo,
data.summary.simon_avg_control
];
charts.simon.update();
// Simon distribution scatter
var simonPlacebo = (data.charts.simon_by_group.placebo || []);
var simonControl = (data.charts.simon_by_group.control || []);
charts.simonDist.data.datasets[0].data = simonPlacebo.map(function(v, i) { return { x: i + 1, y: v }; });
charts.simonDist.data.datasets[1].data = simonControl.map(function(v, i) { return { x: i + 1, y: v }; });
charts.simonDist.update();
// Participant sections
var container = document.getElementById('participant-sections');
var groupLabels = { placebo: 'Placebo Group', control: 'Control Group' };
var html = '';
['placebo', 'control'].forEach(function(groupKey) {
var participants = data.groups[groupKey] || [];
html += '<div class="admin-section" data-group="' + groupKey + '">';
html += '<div class="group-header">';
html += '<h2><span class="badge badge-' + groupKey + '">' + groupLabels[groupKey] + '</span> (' + participants.length + ' participants)</h2>';
html += '<a href="api/export_csv.php?table=all" class="btn btn-secondary btn-sm">Export CSV</a>';
html += '</div>';
if (participants.length === 0) {
html += '<p style="color: #999; padding: 1rem 0;">No participants in this group yet.</p>';
}
participants.forEach(function(pd) {
html += buildParticipantHtml(pd);
});
html += '</div>';
});
container.innerHTML = html;
}
function pollData() {
var indicator = document.getElementById('live-indicator');
indicator.classList.add('fetching');
fetch('api/admin_data.php')
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(function(data) {
updateDashboard(data);
indicator.classList.remove('fetching');
})
.catch(function(err) {
console.error('Live update failed:', err);
indicator.classList.remove('fetching');
indicator.classList.add('error');
setTimeout(function() { indicator.classList.remove('error'); }, 3000);
});
}
// Start polling
setInterval(pollData, POLL_INTERVAL);
</script>
</body>
</html>