Enzo-py's picture
Deploy 2c81099 from GitHub
2a932ac verified
Raw History Blame Contribute Delete
20.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Study Dashboard</title>
<style>
/* Same plain style as the study page: system font, no boxes, one accent. */
:root{
--bg:#ffffff; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e3e3e3; --hover:#f4f4f4;
--accent:#2457a6; --accent-soft:#eef3fb; --warn:#b4530f; --bad:#b3261e;
--font:system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono:ui-monospace, Menlo, Consolas, monospace;
color-scheme:light;
}
@media (prefers-color-scheme: dark){:root{
--bg:#161616; --ink:#ececec; --muted:#a0a0a0; --line:#333333; --hover:#212121;
--accent:#8ab4f8; --accent-soft:#1e2a3d; --warn:#f0a868; --bad:#f2b8b5; color-scheme:dark}}
[hidden]{display:none!important}
html,body{background:var(--bg);color:var(--ink)}
body{margin:0;font-family:var(--font);font-size:14px;line-height:1.5}
main{max-width:1080px;margin:0 auto;padding:32px 16px 64px;display:flex;flex-direction:column;gap:36px}
h1{font-size:22px;font-weight:600;line-height:1.3;margin:0}
h2{font-size:16px;font-weight:600;margin:0 0 2px}
.eyebrow{font-size:13px;color:var(--muted)}
.muted{color:var(--muted)}
.mono{font-family:var(--mono);font-size:12.5px}
header{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:flex-end;justify-content:space-between}
header .meta{display:flex;flex-direction:column;gap:2px;font-size:12.5px;color:var(--muted)}
button{font:500 13px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer}
button:hover{background:var(--hover)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)}
input[type=password]{font:14px var(--font);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;min-width:0;flex:1}
.card{display:flex;flex-direction:column}
main > section.card{border-top:1px solid var(--line);padding-top:20px}
section > .sub{margin:0 0 14px;color:var(--muted);font-size:13px}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px 24px}
.tile b{display:block;font-size:28px;font-weight:600;line-height:1.1;margin:4px 0 2px;font-variant-numeric:tabular-nums}
.tile .eyebrow{font-size:13px}
.tile span.note{font-size:12px;color:var(--muted)}
.warnbox{color:var(--warn);border-left:3px solid var(--warn);padding:2px 0 2px 12px;font-size:13px}
.err{color:var(--bad)}
/* list progress */
.lists{display:flex;flex-direction:column;gap:6px}
.lrow{display:grid;grid-template-columns:56px 1fr 120px;gap:12px;align-items:center}
.track{height:8px;background:var(--hover);border-radius:4px;display:flex;gap:2px;overflow:hidden}
.track i{display:block;height:100%;background:var(--accent);border-radius:0 4px 4px 0}
.track i.pend{background:var(--accent-soft);border-radius:0 4px 4px 0}
.lrow .n{font-size:12.5px;color:var(--muted);text-align:right;font-variant-numeric:tabular-nums}
.lists details>summary{list-style:none;cursor:pointer;border-radius:4px}
.lists details>summary::-webkit-details-marker{display:none}
.lists details>summary:hover{background:var(--hover)}
.lists details[open]>summary .mono{font-weight:600}
.lcontent{display:flex;flex-direction:column;gap:12px;padding:10px 0 16px 68px}
.lcontent h3{font-size:12px;font-weight:500;color:var(--muted);margin:0}
.lstims{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px}
.lstims figure,.lrank figure{margin:0;display:flex;flex-direction:column;gap:4px;font-size:11.5px}
.lstims img,.lrank img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--hover)}
.lstims figure.check img{outline:2px solid var(--warn);outline-offset:-2px}
.lrank{display:flex;flex-direction:column;gap:6px}
.lrank .imgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,110px));gap:8px}
@media (max-width:600px){.lcontent{padding-left:0}}
/* tables */
.tw{overflow-x:auto;margin:0 -4px}
table{border-collapse:collapse;width:100%;font-size:13px}
th{font-size:12px;font-weight:500;color:var(--muted);text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th.sortable{cursor:pointer}
th.sortable:hover,th.sorted{color:var(--ink)}
td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
tr.stim{cursor:pointer}
tr.stim:hover td{background:var(--hover)}
tr.stim[aria-expanded=true] td{background:var(--accent-soft)}
.prompt{font-weight:500}
.tag{display:inline-block;font-size:11.5px;padding:1px 6px;border-radius:4px;background:var(--hover);color:var(--muted);white-space:nowrap}
.tag.ctrl{color:var(--warn)}
.meanbar{display:flex;align-items:center;gap:8px;justify-content:flex-end}
.meanbar .t{width:64px;height:6px;background:var(--hover);border-radius:3px;overflow:hidden}
.meanbar .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0}
.hist{display:inline-flex;align-items:flex-end;gap:2px;height:24px}
.hist i{display:block;width:8px;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
.hist i.zero{background:var(--line)}
/* detail row */
.detail td{background:var(--hover);padding:16px}
.detail .wrap{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-start}
.stimimg{position:relative;width:280px;max-width:100%;line-height:0;border-radius:4px;overflow:hidden}
.stimimg img{width:100%;height:auto}
.stimimg .mk{position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;background:#ff7a1a;opacity:.6;box-shadow:0 0 0 2px #ffffff}
.detail dl{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:13px;max-width:520px}
.detail dt{color:var(--muted)}
.detail dd{margin:0}
/* pairs */
.pairs{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px 28px}
.pair{display:flex;flex-direction:column;gap:6px}
.pair .head{display:flex;justify-content:space-between;align-items:baseline;color:var(--muted);font-size:12.5px}
.pm{display:grid;grid-template-columns:1fr 64px 56px;gap:10px;align-items:center;font-size:13px}
.pm .t{height:6px;background:var(--hover);border-radius:3px;overflow:hidden}
.pm .t i{display:block;height:100%;background:var(--accent);border-radius:0 3px 3px 0}
.pm .v{text-align:right;font-size:12.5px;font-variant-numeric:tabular-nums}
.fail{color:var(--bad);font-weight:500}
.rankings{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:20px 28px}
.rset{display:flex;flex-direction:column;gap:8px}
.rimgs{display:flex;gap:8px}
.rimgs figure{margin:0;flex:1;min-width:0}
.rimgs img{width:100%;height:auto;display:block;border-radius:4px}
.rimgs figcaption{font-size:12px;margin-top:4px;line-height:1.35}
.warn{color:var(--warn)}
#login{max-width:400px;margin:14vh auto;padding:0 16px;display:flex;flex-direction:column;gap:12px}
#login form{display:flex;gap:8px}
@media (max-width:640px){
.lrow{grid-template-columns:48px 1fr 92px}
}
</style>
</head>
<body>
<div id="login" class="card" hidden>
<div class="eyebrow">Partial prompt study</div>
<h1>Dashboard</h1>
<p class="muted" style="margin:0">Enter the <span class="mono">ADMIN_KEY</span> set on the Space.</p>
<form id="loginform"><input type="password" id="key" autocomplete="current-password" placeholder="Admin key" required><button class="primary">Open</button></form>
<p id="loginerr" class="err" hidden></p>
</div>
<main id="app" hidden></main>
<script>
const $ = (s, el = document) => el.querySelector(s);
const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fmt = (v, d = 2) => v == null ? "–" : (+v).toFixed(d);
const pct = v => v == null ? "–" : Math.round(v * 100) + "%";
const FLAG = {attention_check: "Attention check", too_fast: "Many answers under 1 s", same_answers: "Same rating everywhere"};
// "Attention check, Attention check" -> "2 × Attention check"
const flagText = flags => Object.entries(flags.reduce((c, f) => (c[f] = (c[f] || 0) + 1, c), {}))
.map(([f, n]) => `${n > 1 ? n + " × " : ""}${FLAG[f] || f}`).join(", ");
const when = iso => iso ? new Date(iso).toLocaleString(undefined, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"}) : "–";
const params = new URLSearchParams(location.search);
let key = params.get("key") || "";
try { key = key || sessionStorage.getItem("adminKey") || ""; } catch (e) {}
let data = null, sort = {col: "sample_id", dir: 1}, open = new Set();
$("#loginform").onsubmit = e => { e.preventDefault(); key = $("#key").value.trim(); load(); };
async function load(refresh = false) {
if (!key) { $("#login").hidden = false; return; }
const r = await fetch(`/api/summary?key=${encodeURIComponent(key)}${refresh ? "&refresh=true" : ""}`);
if (!r.ok) {
const msg = (await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`;
try { sessionStorage.removeItem("adminKey"); } catch (e) {}
$("#app").hidden = true; $("#login").hidden = false;
$("#loginerr").hidden = false; $("#loginerr").textContent = msg;
return;
}
try { sessionStorage.setItem("adminKey", key); } catch (e) {}
if (params.has("key")) history.replaceState(null, "", location.pathname); // keep the key out of the address bar
data = await r.json();
$("#login").hidden = true; $("#app").hidden = false;
render();
}
function render() {
const d = data, t = d.totals;
const maxList = Math.max(1, ...d.lists.map(l => l.completed + l.in_progress));
const spread = Math.max(...d.lists.map(l => l.completed)) - Math.min(...d.lists.map(l => l.completed));
$("#app").innerHTML = `
<header>
<div><div class="eyebrow">Partial prompt study · ${esc(d.study_version)}</div><h1>Dashboard</h1></div>
<div class="meta">
<span>Stimuli <span class="mono">${esc(d.dataset)}</span></span>
<span>Saving to <span class="mono">${esc(d.saving_to)}</span></span>
<span>Updated ${when(d.generated_at)} · <button id="refresh">Refresh</button></span>
</div>
</header>
${d.saving_to.startsWith("local") ? `<div class="warnbox">Responses are not being pushed to the Hub. They will be lost when the Space restarts. Set <span class="mono">HF_TOKEN</span> and <span class="mono">RESPONSES_REPO</span>.</div>` : ""}
<div class="tiles">
<div class="tile"><span class="eyebrow">Completed</span><b>${t.completed}</b><span class="note">sessions saved</span></div>
<div class="tile"><span class="eyebrow">In progress</span><b>${t.in_progress}</b><span class="note">started in the last 30 min</span></div>
<div class="tile"><span class="eyebrow">Excluded</span><b>${t.excluded}</b><span class="note">${t.excluded ? Object.entries(t.excluded_by).map(([f, n]) => `${FLAG[f] || f} ${n}`).join(" · ") : `failed ${t.exclude_at} checks or more`}${t.flagged ? ` · ${t.flagged} more with warnings` : ""}</span></div>
<div class="tile"><span class="eyebrow">Median time</span><b>${t.median_minutes == null ? "–" : fmt(t.median_minutes, 1) + " min"}</b><span class="note">per completed session</span></div>
</div>
<section class="card">
<h2>Progress per list</h2>
<p class="sub">${d.lists.length} counterbalanced lists. New participants get the least-filled list. Click a list to see its images and rankings. ${t.completed ? `Gap between the fullest and emptiest list: ${spread}.` : ""}</p>
<div class="lists">${d.lists.map(l => `<details>
<summary class="lrow" title="List ${l.list_id}: ${l.completed} completed, ${l.in_progress} in progress, ${l.items} items. Click to see its images and rankings.">
<span class="mono">List ${l.list_id}</span>
<div class="track">${l.completed ? `<i style="width:${l.completed / maxList * 100}%"></i>` : ""}${l.in_progress ? `<i class="pend" style="width:${l.in_progress / maxList * 100}%"></i>` : ""}</div>
<span class="n">${l.completed} done${l.in_progress ? ` · ${l.in_progress} live` : ""}</span>
</summary>
<div class="lcontent">
<h3>${l.stimuli.length} images to rate${l.stimuli.some(s => s.check) ? ` · attention checks outlined` : ""}</h3>
<div class="lstims">${l.stimuli.map(s => `<figure class="${s.check ? "check" : ""}" title="${esc(s.prompt)}">
<img src="/stimuli/${s.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy">
<figcaption><span class="mono">${esc(s.sample_id)}</span>${s.check ? ` <span class="tag">check</span>` : ""}<br>${esc(s.prompt)}</figcaption></figure>`).join("")}</div>
${l.rankings.length ? `<h3>${l.rankings.length} rankings</h3>${l.rankings.map(r => `<div class="lrank">
<span><span class="prompt">${esc(r.prompt)}</span> <span class="mono muted">${esc(r.set_id)}</span></span>
<div class="imgs">${r.images.map(f => `<figure><img src="/stimuli/${f.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy"></figure>`).join("")}</div>
</div>`).join("")}` : ""}
</div></details>`).join("")}
</div>
</section>
<section class="card">
<h2>By category</h2>
<p class="sub">Attention check left out. Rating from 1 (does not match) to 5 (matches). Excluded sessions are left out of every statistic below.</p>
<div class="tw"><table>
<thead><tr><th>Category</th><th class="num">Ratings</th><th class="num">Mean rating</th><th class="num">“Problem” marked</th></tr></thead>
<tbody>${d.categories.map(c => `<tr>
<td><span class="tag">${esc(c.category)}</span> ${esc(c.name)}</td>
<td class="num">${c.n}</td><td class="num">${meanbar(c.mean)}</td><td class="num">${pct(c.problem_rate)}</td></tr>`).join("")}</tbody>
</table></div>
</section>
<section class="card">
<h2>Minimal pairs</h2>
<p class="sub">Members of a pair are seen by different participants. Compare their mean ratings.</p>
<div class="pairs">${d.pairs.map(p => `<div class="pair">
<div class="head"><span class="mono">${esc(p.pair_id)}</span></div>
${p.members.map(m => `<div class="pm" title="${esc(m.sample_id)}: n = ${m.n}">
<span><span class="mono">${esc(m.sample_id)}</span> <span class="prompt">${esc(m.prompt)}</span>${m.pair_role ? ` <span class="tag">${esc(m.pair_role)}</span>` : ""}</span>
<div class="t">${m.mean != null ? `<i style="width:${(m.mean - 1) / 4 * 100}%"></i>` : ""}</div>
<span class="v">${fmt(m.mean)} <span class="muted">n${m.n}</span></span></div>`).join("")}
</div>`).join("")}</div>
</section>
${d.rankings.length ? `<section class="card">
<h2>Rankings</h2>
<p class="sub">Images shown together for one text, ordered by mean rank (1 = favourite). “First” is how often each image was ranked first.</p>
<div class="rankings">${d.rankings.map(r => `<div class="rset">
<div class="head"><span class="prompt">${esc(r.prompt)}</span> <span class="muted">${esc(r.set_id)} · n${r.n}</span></div>
<div class="rimgs">${r.images.map(im => `<figure title="${esc(im.image_id)}${im.sample_id ? " · " + esc(im.sample_id) : ""}${im.case_id ? " · " + esc(im.case_id) : ""}">
<img src="/stimuli/${im.file_name.split("/").map(encodeURIComponent).join("/")}" alt="" loading="lazy">
<figcaption>${im.n ? `<b>${fmt(im.mean_rank, 1)}</b> <span class="muted">${pct(im.first)} first</span><br>` : ""}<span class="mono muted">${esc(im.sample_id || im.image_id)}</span></figcaption>
</figure>`).join("")}</div>
</div>`).join("")}</div>
</section>` : ""}
<section class="card">
<h2>Stimuli</h2>
<p class="sub">Click a row to see the image with every click mark. Click a column header to sort.</p>
<div class="tw"><table id="stimtable"></table></div>
</section>
<section class="card">
<h2>Recent sessions</h2>
<p class="sub">The last ${d.sessions.length} sessions, newest first.</p>
<div class="tw"><table>
<thead><tr><th>Received</th><th>Session</th><th class="num">List</th><th class="num">Time</th><th>Source</th><th>English</th><th>T2I use</th><th>Quality</th></tr></thead>
<tbody>${d.sessions.map(s => `<tr>
<td class="num">${when(s.received_at)}</td><td class="mono">${esc(s.session)}</td><td class="num">${s.list_id}</td>
<td class="num">${s.minutes == null ? "–" : fmt(s.minutes, 1) + " min"}</td>
<td>${s.prolific ? "Prolific" : "Direct"}</td><td>${esc(s.english)}</td><td>${esc(s.t2i)}</td>
<td>${s.excluded ? `<span class="fail">✕ Excluded: ${flagText(s.flags)}</span>`
: s.flags.length ? `<span class="warn">⚠ ${flagText(s.flags)}</span>` : "✓ Passed"}</td></tr>`).join("") || `<tr><td colspan="8" class="muted">No sessions yet.</td></tr>`}</tbody>
</table></div>
</section>`;
$("#refresh").onclick = () => load(true);
renderStimuli();
}
function meanbar(m) {
return `<span class="meanbar">${fmt(m)}<span class="t">${m != null ? `<i style="width:${(m - 1) / 4 * 100}%"></i>` : ""}</span></span>`;
}
const COLS = [
["sample_id", "ID"], ["prompt", "Prompt"], ["case_id", "Case"],
["n", "n", 1], ["mean", "Mean rating", 1], ["dist", "Ratings 1–5"], ["problem_rate", "Problem", 1],
["median_rt_s", "Median RT", 1],
];
function renderStimuli() {
const rows = [...data.stimuli].sort((a, b) => {
const x = a[sort.col], y = b[sort.col];
if (x == null) return 1; if (y == null) return -1;
return (x > y ? 1 : x < y ? -1 : 0) * sort.dir;
});
const tbl = $("#stimtable");
tbl.innerHTML = `<thead><tr>${COLS.map(([k, label, num]) =>
`<th class="${num ? "num " : ""}${k !== "dist" ? "sortable" : ""}${sort.col === k ? " sorted" : ""}" data-k="${k}">${label}${sort.col === k ? (sort.dir > 0 ? " ↑" : " ↓") : ""}</th>`).join("")}</tr></thead>
<tbody>${rows.map(s => {
const peak = Math.max(1, ...s.dist);
return `<tr class="stim" data-id="${esc(s.sample_id)}" aria-expanded="${open.has(s.sample_id)}" tabindex="0">
<td class="mono">${esc(s.sample_id)}</td>
<td><span class="prompt">${esc(s.prompt)}</span></td>
<td>${s.attention_check ? `<span class="tag ctrl">attention check</span>` : `<span class="tag">${esc(s.case_id)}</span>`}</td>
<td class="num">${s.n}</td>
<td class="num">${meanbar(s.mean)}</td>
<td><span class="hist" title="${s.dist.map((c, i) => `${i + 1}: ${c}`).join(" · ")}">${s.dist.map(c => `<i class="${c ? "" : "zero"}" style="height:${c ? Math.max(3, c / peak * 26) : 1}px"></i>`).join("")}</span></td>
<td class="num">${pct(s.problem_rate)}</td>
<td class="num">${s.median_rt_s == null ? "–" : fmt(s.median_rt_s, 1) + " s"}</td>
</tr>${open.has(s.sample_id) ? detail(s) : ""}`;
}).join("")}</tbody>`;
tbl.querySelectorAll("th.sortable").forEach(th => th.onclick = () => {
const k = th.dataset.k;
sort = {col: k, dir: sort.col === k ? -sort.dir : (["n", "mean", "problem_rate", "median_rt_s"].includes(k) ? -1 : 1)};
renderStimuli();
});
tbl.querySelectorAll("tr.stim").forEach(tr => {
const toggle = () => { const id = tr.dataset.id; open.has(id) ? open.delete(id) : open.add(id); renderStimuli(); };
tr.onclick = toggle;
tr.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } };
});
}
function detail(s) {
return `<tr class="detail"><td colspan="${COLS.length}"><div class="wrap">
<div class="stimimg"><img src="/stimuli/${encodeURIComponent(s.file_name)}" alt="${esc(s.prompt)}">
${s.marks.map(([x, y]) => `<div class="mk" style="left:${x * 100}%;top:${y * 100}%"></div>`).join("")}</div>
<dl>
<dt>Category</dt><dd><span class="tag">${esc(s.category)}</span> ${esc(s.category_name)} · ${esc(s.subcategory)}</dd>
<dt>Ratings</dt><dd>${s.dist.map((n, i) => `${i + 1}: <b>${n}</b>`).join(" · ")}</dd>
<dt>Click marks</dt><dd>${s.marks.length}</dd>
${s.segments && s.segments.length ? `<dt>Parts selected</dt><dd>${s.segments.map(([c, n]) => `${esc(c)} ${n}`).join(", ")}</dd>` : ""}
</dl></div></td></tr>`;
}
load();
</script>
</body>
</html>