shaanilmo's picture
Dashboard: scope donut + total + count to the active filter
cce29f4
Raw
History Blame Contribute Delete
36.4 kB
"""
ASR Annotation — Team Progress Dashboard
========================================
A tiny FastAPI service deployed as a Hugging Face Space. Annotators using the
client-side labeling webapp click "Share Progress", which POSTs a *counts-only*
snapshot here. Each report is persisted as one file per annotator inside a
private HF Dataset repo (the Space filesystem is ephemeral; the Dataset is the
durable source of truth). The dashboard reads all reports and shows per-person
and aggregate stats.
No transcripts or audio ever reach this service — only tallies.
Environment / Space secrets
---------------------------
HF_TOKEN Write token for the account that owns the Dataset repo.
DATASET_REPO e.g. "shaanilmo/asr-annotation-progress".
REPORT_TOKEN Shared string the webapp must send in the X-Report-Token header.
"""
import json
import os
import re
import time
from datetime import datetime, timezone
from fastapi import FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from huggingface_hub import HfApi, hf_hub_download, list_repo_files
from pydantic import BaseModel
# ----------------------------------------------------------------------------
# Config
# ----------------------------------------------------------------------------
HF_TOKEN = os.environ.get("HF_TOKEN")
DATASET_REPO = os.environ.get("DATASET_REPO", "shaanilmo/asr-annotation-progress")
REPORT_TOKEN = os.environ.get("REPORT_TOKEN", "")
REPORTS_DIR = "reports"
STATUS_KEYS = ["pending", "delete", "correct", "to_correct", "edited"]
api = HfApi(token=HF_TOKEN)
app = FastAPI(title="ASR Annotation Progress")
# Annotators open the labeling page from file:// or some static host, so allow any
# origin. Only POST/GET are used and the payload carries no secrets beyond the
# shared report token, which is validated explicitly below.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
def ensure_dataset_repo():
"""Create the private Dataset repo on first boot if it doesn't exist yet."""
if not HF_TOKEN:
# Let the app still boot so the dashboard can render a helpful message.
print("WARNING: HF_TOKEN not set — reports cannot be persisted.")
return
try:
api.create_repo(
repo_id=DATASET_REPO,
repo_type="dataset",
private=True,
exist_ok=True,
)
except Exception as exc: # pragma: no cover - best effort
print(f"WARNING: could not ensure dataset repo {DATASET_REPO}: {exc}")
# ----------------------------------------------------------------------------
# Models
# ----------------------------------------------------------------------------
class Counts(BaseModel):
pending: int = 0
delete: int = 0
correct: int = 0
to_correct: int = 0
edited: int = 0
class Report(BaseModel):
annotator: str
team: str = ""
intern: bool = False
recoveryUsed: bool = False
folder: str = ""
total: int = 0
counts: Counts
# Per-status audio duration in seconds (mirrors counts) for the time view.
# Optional/back-compatible: old reports omit these and render as no-time.
durations: dict = {}
durationTotal: float = 0
durationKnown: int = 0
updatedAt: int = 0
# Known teams; anything else is treated as Unassigned.
TEAMS = {"Research", "Investments", "Growth", "ConvAI", "HyperPersonalization"}
# ----------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------
def slugify(name: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", name.strip()).strip("_").lower()
return slug or "anonymous"
def _read_all_reports() -> list[dict]:
"""Download every reports/*.json from the Dataset and return parsed dicts."""
try:
files = list_repo_files(DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
except Exception:
return []
reports = []
for path in files:
if not (path.startswith(REPORTS_DIR + "/") and path.endswith(".json")):
continue
try:
local = hf_hub_download(
DATASET_REPO,
filename=path,
repo_type="dataset",
token=HF_TOKEN,
)
with open(local, "r", encoding="utf-8") as fh:
reports.append(json.load(fh))
except Exception:
continue
reports.sort(key=lambda r: r.get("updatedAt", 0), reverse=True)
return reports
def _dedupe_per_folder(reports: list[dict]) -> list[dict]:
"""Collapse to the latest report per (annotator, folder). Handles re-shares and
any legacy-flat vs new-nested duplicate of the same folder (no double-counting)."""
latest: dict[tuple, dict] = {}
for r in reports:
key = (slugify(r.get("annotator", "")), (r.get("folder") or "").strip().lower())
cur = latest.get(key)
if cur is None or int(r.get("updatedAt", 0) or 0) >= int(cur.get("updatedAt", 0) or 0):
latest[key] = r
return list(latest.values())
def _group_by_annotator(reports: list[dict]) -> list[dict]:
"""Sum a person's deduped per-folder reports into one row + a folders breakdown."""
groups: dict[str, dict] = {}
for r in reports:
slug = slugify(r.get("annotator", ""))
counts = r.get("counts", {}) or {}
g = groups.get(slug)
if g is None:
g = groups[slug] = {
"annotator": r.get("annotator", "Anonymous"),
"slug": slug,
"team": r.get("team", "") or "",
"intern": bool(r.get("intern", False)),
"recoveryUsed": False,
"total": 0,
"counts": {k: 0 for k in STATUS_KEYS},
"durations": {k: 0.0 for k in STATUS_KEYS},
"durationTotal": 0.0,
"durationKnown": 0,
"updatedAt": 0,
"folders": [],
}
durs = r.get("durations", {}) or {}
g["total"] += int(r.get("total", 0) or 0)
for k in STATUS_KEYS:
g["counts"][k] += int(counts.get(k, 0) or 0)
g["durations"][k] += float(durs.get(k, 0) or 0)
g["durationTotal"] += float(r.get("durationTotal", 0) or 0)
g["durationKnown"] += int(r.get("durationKnown", 0) or 0)
g["recoveryUsed"] = g["recoveryUsed"] or bool(r.get("recoveryUsed", False))
if bool(r.get("intern", False)):
g["intern"] = True
ru = int(r.get("updatedAt", 0) or 0)
if ru >= g["updatedAt"]:
g["updatedAt"] = ru
if r.get("team"):
g["team"] = r.get("team") # latest non-empty team wins
g["folders"].append({
"folder": r.get("folder", "") or "",
"total": int(r.get("total", 0) or 0),
"counts": counts,
"updatedAt": ru,
})
for g in groups.values():
g["folders"].sort(key=lambda f: f.get("updatedAt", 0), reverse=True)
return list(groups.values())
def _aggregate(reports: list[dict]) -> dict:
totals = {k: 0 for k in STATUS_KEYS}
dur_totals = {k: 0.0 for k in STATUS_KEYS}
grand_total = 0
dur_grand_total = 0.0
dur_known = 0
for r in reports:
grand_total += int(r.get("total", 0) or 0)
counts = r.get("counts", {}) or {}
durs = r.get("durations", {}) or {}
for k in STATUS_KEYS:
totals[k] += int(counts.get(k, 0) or 0)
dur_totals[k] += float(durs.get(k, 0) or 0)
dur_grand_total += float(r.get("durationTotal", 0) or 0)
dur_known += int(r.get("durationKnown", 0) or 0)
# "Done" = files whose pipeline is actually finished. A 'to_correct' file is
# triaged but still needs editing, so it does NOT count toward completion
# (neither does 'pending'). The remaining backlog is pending + to_correct.
done = totals["delete"] + totals["correct"] + totals["edited"]
return {
"total": grand_total,
"counts": totals,
"durations": dur_totals,
"durationTotal": dur_grand_total,
"durationKnown": dur_known,
"done": done,
"to_fix": totals["to_correct"],
"percent": round(100 * done / grand_total) if grand_total else 0,
}
# ----------------------------------------------------------------------------
# Routes
# ----------------------------------------------------------------------------
@app.post("/report")
def submit_report(report: Report, x_report_token: str = Header(default="")):
if not REPORT_TOKEN or x_report_token != REPORT_TOKEN:
raise HTTPException(status_code=403, detail="Invalid or missing report token.")
if not HF_TOKEN:
raise HTTPException(status_code=500, detail="Server missing HF_TOKEN.")
slug = slugify(report.annotator)
# One file per (annotator, folder) so a person's folders accumulate on the
# dashboard instead of overwriting each other.
folder_slug = slugify(report.folder) if report.folder else "unknown"
payload = report.model_dump()
payload["annotator"] = report.annotator.strip() or "Anonymous"
payload["team"] = report.team if report.team in TEAMS else ""
payload["slug"] = slug
payload["receivedAt"] = int(time.time() * 1000)
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
try:
api.upload_file(
path_or_fileobj=data,
path_in_repo=f"{REPORTS_DIR}/{slug}/{folder_slug}.json",
repo_id=DATASET_REPO,
repo_type="dataset",
commit_message=f"progress: {payload['annotator']} · {report.folder or '?'} ({report.total} segs)",
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Could not persist report: {exc}")
return {"ok": True, "annotator": payload["annotator"], "slug": slug}
@app.get("/api/stats")
def stats():
deduped = _dedupe_per_folder(_read_all_reports())
return JSONResponse(
{
"annotators": _group_by_annotator(deduped),
"aggregate": _aggregate(deduped),
"generatedAt": int(time.time() * 1000),
}
)
@app.get("/", response_class=HTMLResponse)
def dashboard():
return HTMLResponse(DASHBOARD_HTML)
# ----------------------------------------------------------------------------
# Dashboard (single self-contained page; fetches /api/stats and re-renders)
# ----------------------------------------------------------------------------
DASHBOARD_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ASR Annotation — Team Progress</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' fill='none'%3E%3Cdefs%3E%3ClinearGradient id='c' x1='50' y1='8' x2='50' y2='92' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0' stop-color='%23FFD23F'/%3E%3Cstop offset='1' stop-color='%23F5A623'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d='M 82.2 23 A 42 42 0 1 0 82.2 77' stroke='url(%23c)' stroke-width='9' stroke-linecap='round'/%3E%3C/svg%3E">
<style>
:root {
--bg: #0f1419; --panel: #1a212b; --panel-2: #222b38; --line: #2c3848;
--text: #e7edf3; --muted: #8a99ab;
--delete: #ef5350; --correct: #66bb6a; --to_correct: #ffa726; --edited: #42a5f5;
--pending: #56657a; --accent: #f5a623;
}
html[data-theme="light"] {
--bg: #f5f7fa; --panel: #ffffff; --panel-2: #eef2f7; --line: #dce3ec;
--text: #1a2230; --muted: #5c6b7e;
--delete: #e53935; --correct: #43a047; --to_correct: #fb8c00; --edited: #1e88e5;
--pending: #b6c0cd; --accent: #d98300;
}
* { box-sizing: border-box; }
button, select, input, textarea, label { font-family: inherit; }
body { margin: 0; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); padding: 32px; }
h1 { font-size: 22px; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 24px; }
.sub b { color: var(--accent); }
table { width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
th, td { padding: 12px 14px; text-align: left; font-size: 14px; }
th { background: var(--panel-2); color: var(--muted); font-weight: 600;
text-transform: uppercase; font-size: 11px; letter-spacing: .04em; }
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
th.sortable:hover { color: var(--text); }
.sort-arrow { display: inline-block; width: 1em; margin-left: 2px; font-size: 9px; }
.sort-arrow.active { color: var(--accent); }
tr + tr td { border-top: 1px solid var(--line); }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.name { font-weight: 600; }
.pct-wrap { display: flex; align-items: center; gap: 10px; min-width: 180px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 12px;
font-variant-numeric: tabular-nums; }
.pill.delete { background: rgba(239,83,80,.15); color: var(--delete); }
.pill.correct { background: rgba(102,187,106,.15); color: var(--correct); }
.pill.to_correct { background: rgba(255,167,38,.15); color: var(--to_correct); }
.pill.edited { background: rgba(66,165,245,.15); color: var(--edited); }
.muted { color: var(--muted); }
tfoot td { border-top: 2px solid var(--line); font-weight: 700; background: var(--panel-2); }
.empty { text-align: center; padding: 48px; color: var(--muted); }
.updated { font-size: 12px; color: var(--muted); margin-top: 14px; }
.topbar { display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; }
.theme-toggle { background: var(--panel); color: var(--text); border: 1px solid var(--line);
border-radius: 99px; padding: 7px 14px; font-size: 13px; cursor: pointer;
display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.theme-toggle:hover { border-color: var(--accent); }
.topbar-controls { display: flex; align-items: center; gap: 10px;
flex-wrap: wrap; justify-content: flex-end; }
.filter-toggle { display: flex; align-items: center; gap: 7px; cursor: pointer;
background: var(--panel); color: var(--text); border: 1px solid var(--line);
border-radius: 99px; padding: 7px 14px; font-size: 13px; white-space: nowrap; }
.filter-toggle:hover { border-color: var(--accent); }
.filter-toggle input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
.team-filter { appearance: none; -webkit-appearance: none; -moz-appearance: none;
background-color: var(--panel); color: var(--text); border: 1px solid var(--line);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 14px center; background-size: 12px;
border-radius: 99px; padding: 7px 34px 7px 16px; font-size: 13px; cursor: pointer; }
.team-filter:hover { border-color: var(--accent); }
/* ---- charts ---- */
.charts { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 28px; }
.chart-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 18px; }
.chart-title { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--muted); font-weight: 600; margin-bottom: 14px; }
.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.donut { flex: 0 0 auto; }
.donut-center { font-weight: 700; }
.chart-legend { display: flex; flex-direction: column; gap: 6px; font-size: 12px; min-width: 150px; }
.legend-item { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.legend-item .count { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.swatch { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
/* rank / podium column */
th.rank-h { width: 44px; text-align: center; }
td.rank { width: 44px; text-align: center; color: var(--muted);
font-variant-numeric: tabular-nums; }
.medal { display: inline-flex; align-items: center; justify-content: center;
width: 25px; height: 25px; border-radius: 50%; font-weight: 700; font-size: 12px;
color: #1a1205; box-shadow: inset 0 0 0 1px rgba(0,0,0,.18), 0 1px 3px rgba(0,0,0,.25); }
.medal.gold { background: linear-gradient(145deg, #ffe487, #f5b301); }
.medal.silver { background: linear-gradient(145deg, #eef2f5, #b4bdc7); }
.medal.bronze { background: linear-gradient(145deg, #f0b27a, #c2772f); }
/* team cell: colored chip + neutral status tags */
.team-chip { display: inline-block; padding: 2px 9px; border-radius: 999px;
font-size: 12px; font-weight: 600; border: 1px solid; }
.tag-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 99px; font-size: 11px;
font-weight: 600; line-height: 1.5; background: transparent;
border: 1px solid var(--line); color: var(--muted); }
.tag.recovery-yes { background: rgba(245,158,11,.16); color: #f59e0b; border-color: rgba(245,158,11,.4); }
/* in-row stacked status bar (table Progress column) */
.bar-track { flex: 1; display: flex; height: 14px; border-radius: 4px; overflow: hidden;
background: var(--panel-2); }
.bar-track .seg { height: 100%; }
</style>
<script>
// Apply saved theme before first paint to avoid a flash. Default: dark.
(function () {
var t = localStorage.getItem("asr_dash_theme") || "dark";
document.documentElement.setAttribute("data-theme", t);
})();
</script>
</head>
<body>
<div class="topbar">
<div>
<h1>ASR Annotation — Team Progress</h1>
<div class="sub">Live dashboard · auto-refreshes every 15s · <b id="count">0</b> annotators reporting</div>
</div>
<div class="topbar-controls">
<select class="team-filter" id="team-filter" title="Filter by team">
<option value="">All teams</option>
<option value="Research">Research</option>
<option value="Investments">Investments</option>
<option value="Growth">Growth</option>
<option value="ConvAI">ConvAI</option>
<option value="HyperPersonalization">HyperPersonalization</option>
<option value="__unassigned__">Unassigned</option>
</select>
<select class="team-filter" id="intern-filter" title="Filter by intern status">
<option value="">All annotators</option>
<option value="only">Interns only</option>
<option value="exclude">Exclude interns</option>
</select>
<select class="team-filter" id="unit-filter" title="Measure progress by file count or audio duration">
<option value="files">Files</option>
<option value="time">Time (h:m)</option>
</select>
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark"></button>
</div>
</div>
<div class="charts" id="charts"></div>
<div id="table-wrap"></div>
<div class="updated" id="updated"></div>
<script>
const STATUS = ["delete", "correct", "to_correct", "edited", "pending"];
const LABEL = { delete: "Delete", correct: "Correct", to_correct: "To Fix", edited: "Edited", pending: "Pending" };
const TEAMS = new Set(["Research", "Investments", "Growth", "ConvAI", "HyperPersonalization"]);
// Per-team palette: tinted chip (bg/border/text) + a stronger rail color for the
// row's left edge. Light fills read fine on both dark and light dashboard themes.
const TEAM_COLORS = {
Research: { bg: "#EEEDFE", border: "#AFA9EC", text: "#3C3489", rail: "#534AB7" },
Investments: { bg: "#EAF3DE", border: "#97C459", text: "#27500A", rail: "#3B6D11" },
Growth: { bg: "#FAEEDA", border: "#EF9F27", text: "#633806", rail: "#854F0B" },
ConvAI: { bg: "#E6F1FB", border: "#85B7EB", text: "#0C447C", rail: "#185FA5" },
HyperPersonalization: { bg: "#FBEAF0", border: "#ED93B1", text: "#72243E", rail: "#993556" },
};
const UNASSIGNED_COLOR = { bg: "#F1EFE8", border: "#B4B2A9", text: "#444441", rail: "#888780" };
// Chart segment order (done group first, backlog, then pending) + color var.
const CHART_ORDER = [
["correct", "--correct"], ["edited", "--edited"], ["delete", "--delete"],
["to_correct", "--to_correct"], ["pending", "--pending"],
];
// ---- Theme toggle (persisted; default dark) ----
const themeBtn = document.getElementById("theme-toggle");
function renderThemeBtn() {
const isLight = document.documentElement.getAttribute("data-theme") === "light";
themeBtn.textContent = isLight ? "🌙 Dark" : "☀️ Light";
}
themeBtn.addEventListener("click", () => {
const isLight = document.documentElement.getAttribute("data-theme") === "light";
const next = isLight ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("asr_dash_theme", next);
renderThemeBtn();
});
renderThemeBtn();
// ---- Intern filter (persisted) ----
let internFilter = localStorage.getItem("asr_dash_intern_filter") || "";
const internSel = document.getElementById("intern-filter");
internSel.value = internFilter;
internSel.addEventListener("change", () => {
internFilter = internSel.value;
localStorage.setItem("asr_dash_intern_filter", internFilter);
refresh();
});
// ---- Team filter (persisted) ----
let teamFilter = localStorage.getItem("asr_dash_team") || "";
const teamSel = document.getElementById("team-filter");
teamSel.value = teamFilter;
teamSel.addEventListener("change", () => {
teamFilter = teamSel.value;
localStorage.setItem("asr_dash_team", teamFilter);
refresh();
});
// ---- Unit toggle: file counts vs audio duration (persisted) ----
let unit = localStorage.getItem("asr_dash_unit") || "files";
const unitSel = document.getElementById("unit-filter");
unitSel.value = unit;
unitSel.addEventListener("change", () => {
unit = unitSel.value;
localStorage.setItem("asr_dash_unit", unit);
refresh();
});
// The active per-status metric object and its total for a report/aggregate row.
function metric(r) { return (unit === "time" ? r.durations : r.counts) || {}; }
function metricTotal(r) { return (unit === "time" ? r.durationTotal : r.total) || 0; }
// In time mode a row with no measured audio yet has nothing to show.
function noTime(r) { return unit === "time" && !metricTotal(r); }
// Seconds → "Xh Ym" (or "Ym" / "<1m"); files render as the plain integer.
function secondsToHM(s) {
s = Math.round(s || 0);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h) return h + "h " + m + "m";
if (m) return m + "m";
return s > 0 ? "<1m" : "0m";
}
function fmt(v) { return unit === "time" ? secondsToHM(v) : String(v || 0); }
function fmtTime(ms) {
if (!ms) return "—";
const d = new Date(ms);
const diff = (Date.now() - ms) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
return d.toLocaleDateString();
}
// Completion % for a single annotator. "Done" excludes the to-fix backlog:
// only delete + correct + edited count as finished (pending/to_correct do not).
function done(c) {
return (c.delete || 0) + (c.correct || 0) + (c.edited || 0);
}
function pct(c, total) {
if (!total) return 0;
return Math.round(100 * done(c) / total);
}
// ---- Sorting (client-side; default = Done count, most first) ----
let sortKey = "done";
let sortDir = "desc";
// Columns whose natural "first click" should be ascending; everything else
// (counts, completion) defaults to ascending too so laggards/low values lead.
const TEXT_COLS = new Set(["name"]);
function sortValue(r, key) {
const c = metric(r); // sorting tracks the active unit (files or time)
const t = metricTotal(r);
switch (key) {
case "name": return (r.annotator || "").toLowerCase();
case "team": return (r.team || "").toLowerCase();
case "recovery": return r.recoveryUsed ? 1 : 0;
case "total": return t;
case "delete": return c.delete || 0;
case "correct": return c.correct || 0;
case "to_correct": return c.to_correct || 0;
case "edited": return c.edited || 0;
case "pending": return c.pending || 0;
case "done": return done(c);
case "updated": return r.updatedAt || 0;
case "completion": return pct(c, t);
default: return 0;
}
}
function sortReports(reports) {
const dir = sortDir === "asc" ? 1 : -1;
return reports.slice().sort((a, b) => {
const va = sortValue(a, sortKey), vb = sortValue(b, sortKey);
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return 0;
});
}
function setSort(key) {
if (key === sortKey) {
sortDir = sortDir === "asc" ? "desc" : "asc";
} else {
sortKey = key;
sortDir = TEXT_COLS.has(key) ? "asc" : "asc";
}
refresh();
}
function arrow(key) {
if (key !== sortKey) return '<span class="sort-arrow"></span>';
return '<span class="sort-arrow active">' + (sortDir === "asc" ? "▲" : "▼") + "</span>";
}
// ---- Charts (inline SVG/CSS; no library) ----
function renderDonut(counts, total, percent) {
const r = 52, cx = 70, cy = 70, C = 2 * Math.PI * r;
let acc = 0, segs = "";
CHART_ORDER.forEach(([k, varName]) => {
const v = counts[k] || 0;
if (!v || !total) return;
const len = C * v / total;
segs += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(${varName})" `
+ `stroke-width="20" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" `
+ `stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"></circle>`;
acc += len;
});
if (!segs) segs = `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--panel-2)" stroke-width="20"></circle>`;
return `<svg class="donut" width="140" height="140" viewBox="0 0 140 140">${segs}`
+ `<text x="70" y="67" text-anchor="middle" font-size="22" class="donut-center" fill="var(--text)">${percent}%</text>`
+ `<text x="70" y="85" text-anchor="middle" font-size="11" fill="var(--muted)">done</text></svg>`;
}
function renderLegend(counts) {
return `<div class="chart-legend">` + CHART_ORDER.map(([k, varName]) =>
`<div class="legend-item"><span class="swatch" style="background:var(${varName})"></span>`
+ `${LABEL[k]}<span class="count">${fmt(counts[k] || 0)}</span></div>`).join("") + `</div>`;
}
// In-row stacked status bar, scaled to that row's own total (full width = total),
// so each row shows its composition. title attrs give hover counts.
function rowBar(c, total) {
const segs = CHART_ORDER.map(([k, varName]) => {
const v = c[k] || 0;
if (!v || !total) return "";
return `<span class="seg" style="width:${(100 * v / total).toFixed(3)}%;background:var(${varName})" title="${LABEL[k]}: ${fmt(v)}"></span>`;
}).join("");
return `<div class="bar-track">${segs}</div>`;
}
// Client-side aggregate over a set of grouped annotator rows — mirrors the
// server's _aggregate so the donut/legend/total can reflect the current filter.
// Equals the server "aggregate" when given the whole (unfiltered) list.
function aggregateRows(rows) {
const counts = {}, durations = {};
STATUS.forEach(k => { counts[k] = 0; durations[k] = 0; });
let total = 0, durationTotal = 0, durationKnown = 0;
rows.forEach(r => {
const c = r.counts || {}, d = r.durations || {};
STATUS.forEach(k => { counts[k] += c[k] || 0; durations[k] += d[k] || 0; });
total += r.total || 0;
durationTotal += r.durationTotal || 0;
durationKnown += r.durationKnown || 0;
});
const doneN = counts.delete + counts.correct + counts.edited;
return {
counts, durations, total, durationTotal, durationKnown,
done: doneN, percent: total ? Math.round(100 * doneN / total) : 0,
};
}
function renderCharts(reports, agg) {
const el = document.getElementById("charts");
if (!reports.length) { el.innerHTML = ""; return; }
const m = metric(agg), t = metricTotal(agg);
const p = pct(m, t); // unit-correct (in files mode this equals agg.percent)
const title = unit === "time" ? "Team status · by audio time" : "Team status";
el.innerHTML =
`<div class="chart-card"><div class="chart-title">${title}</div>`
+ `<div class="donut-wrap">${renderDonut(m, t, p)}${renderLegend(m)}</div></div>`;
}
async function refresh() {
let data;
try {
const res = await fetch("api/stats", { cache: "no-store" });
data = await res.json();
} catch (e) {
document.getElementById("table-wrap").innerHTML =
'<div class="empty">Could not load stats.</div>';
return;
}
const reports = sortReports(data.annotators || []);
if (!reports.length) {
renderCharts([], null);
document.getElementById("count").textContent = 0;
document.getElementById("table-wrap").innerHTML =
'<div class="empty">No reports yet. Annotators appear here after clicking “Share Progress”.</div>';
return;
}
// Podium medals only when the table is a genuine leaderboard: sorted
// descending on an "achievement" column (Done is the default). Otherwise the
// rank column just shows the position in the current view.
const PODIUM_KEYS = new Set(["done", "completion", "correct", "edited"]);
const podium = sortDir === "desc" && PODIUM_KEYS.has(sortKey);
const MEDALS = ["gold", "silver", "bronze"];
// Apply the team/intern filters, then scope the donut, the Team-total row, and
// the header count to that selection (whole team when no filter is active).
let tableReports = reports;
if (internFilter === "only") tableReports = tableReports.filter(r => r.intern);
else if (internFilter === "exclude") tableReports = tableReports.filter(r => !r.intern);
if (teamFilter === "__unassigned__") tableReports = tableReports.filter(r => !TEAMS.has(r.team));
else if (teamFilter) tableReports = tableReports.filter(r => r.team === teamFilter);
const isFiltered = internFilter !== "" || teamFilter !== "";
const viewAgg = aggregateRows(tableReports);
document.getElementById("count").textContent = tableReports.length;
renderCharts(tableReports, viewAgg);
if (!tableReports.length) {
document.getElementById("table-wrap").innerHTML =
'<div class="empty">No annotators match the current filter.</div>';
document.getElementById("updated").textContent =
"Last refreshed " + new Date().toLocaleTimeString();
return;
}
const rows = tableReports.map((r, i) => {
const c = metric(r);
const mt = metricTotal(r);
const nt = noTime(r); // time mode + no measured audio yet
const F = (v) => nt ? "—" : fmt(v); // dash out numeric cells with no time data
const p = pct(c, mt);
const rank = i + 1;
const team = TEAMS.has(r.team) ? r.team : "";
const col = TEAM_COLORS[team] || UNASSIGNED_COLOR;
const rankInner = (podium && rank <= 3) ? `<span class="medal ${MEDALS[rank - 1]}">${rank}</span>` : `${rank}`;
const rankCell = `<td class="rank" style="border-left:3px solid ${col.rail}">${rankInner}</td>`;
const tags = r.intern ? `<span class="tag">Intern</span>` : "";
const chip = `<span class="team-chip" style="color:${col.text};border-color:${col.border};background:${col.bg}">`
+ `${escapeHtml(team || "Unassigned")}</span>`;
const teamCell = `<td class="team">${chip}${tags ? `<div class="tag-row">${tags}</div>` : ""}</td>`;
const recoveryCell = `<td class="recovery-col">`
+ (r.recoveryUsed
? `<span class="tag recovery-yes" title="Used skip-ahead recovery (detected or self-declared); these numbers are partly self-declared">Recovery</span>`
: `<span class="muted">—</span>`) + `</td>`;
return `<tr>
${rankCell}
<td class="name">${escapeHtml(r.annotator || "—")}<div class="muted" style="font-weight:400;font-size:12px">${(r.folders || []).map(f => escapeHtml(f.folder || "")).filter(Boolean).join(", ")}</div></td>
${teamCell}
${recoveryCell}
<td class="num">${F(mt)}</td>
<td><span class="pill delete">${F(c.delete || 0)}</span></td>
<td><span class="pill correct">${F(c.correct || 0)}</span></td>
<td><span class="pill to_correct">${F(c.to_correct || 0)}</span></td>
<td><span class="pill edited">${F(c.edited || 0)}</span></td>
<td class="num muted">${F(c.pending || 0)}</td>
<td class="num"><strong>${F(done(c))}</strong></td>
<td>${nt ? '<span class="muted" title="No audio lengths measured yet">—</span>' : `<div class="pct-wrap">${rowBar(c, mt)}<span>${p}%</span></div>`}</td>
<td class="muted">${fmtTime(r.updatedAt)}</td>
</tr>`;
}).join("");
const ac = metric(viewAgg);
const at = metricTotal(viewAgg);
const ant = noTime(viewAgg);
const AF = (v) => ant ? "—" : fmt(v);
const ap = pct(ac, at); // unit-correct completion for the shown set
const foot = `<tr>
<td class="rank"></td>
<td>${isFiltered ? "Total (shown)" : "Team total"}</td>
<td></td>
<td></td>
<td class="num">${AF(at)}</td>
<td><span class="pill delete">${AF(ac.delete || 0)}</span></td>
<td><span class="pill correct">${AF(ac.correct || 0)}</span></td>
<td><span class="pill to_correct">${AF(ac.to_correct || 0)}</span></td>
<td><span class="pill edited">${AF(ac.edited || 0)}</span></td>
<td class="num">${AF(ac.pending || 0)}</td>
<td class="num">${AF(done(ac))}</td>
<td>${ant ? '<span class="muted">—</span>' : `<div class="pct-wrap">${rowBar(ac, at)}<span>${ap}%</span></div>`}</td>
<td></td>
</tr>`;
document.getElementById("table-wrap").innerHTML = `<table>
<thead><tr>
<th class="rank-h" title="Rank in the current sort">#</th>
<th data-sort="name" class="sortable">Annotator${arrow("name")}</th>
<th data-sort="team" class="sortable">Team${arrow("team")}</th>
<th data-sort="recovery" class="sortable" title="Used skip-ahead recovery (detected or self-declared)">Recovery${arrow("recovery")}</th>
<th data-sort="total" class="sortable num">Total${arrow("total")}</th>
<th data-sort="delete" class="sortable">Delete${arrow("delete")}</th>
<th data-sort="correct" class="sortable">Correct${arrow("correct")}</th>
<th data-sort="to_correct" class="sortable">To Fix${arrow("to_correct")}</th>
<th data-sort="edited" class="sortable">Edited${arrow("edited")}</th>
<th data-sort="pending" class="sortable num">Pending${arrow("pending")}</th>
<th data-sort="done" class="sortable num" title="Edited + Deleted + Correct">Done${arrow("done")}</th>
<th data-sort="completion" class="sortable">Progress${arrow("completion")}</th>
<th data-sort="updated" class="sortable">Updated${arrow("updated")}</th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot>${foot}</tfoot>
</table>`;
// (Re)bind header sorting after each render.
document.querySelectorAll("th[data-sort]").forEach(th => {
th.addEventListener("click", () => setSort(th.dataset.sort));
});
document.getElementById("updated").textContent =
"Last refreshed " + new Date().toLocaleTimeString();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, m =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[m]));
}
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>
"""