| """ |
| 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 |
|
|
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| 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: |
| |
| 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: |
| print(f"WARNING: could not ensure dataset repo {DATASET_REPO}: {exc}") |
|
|
|
|
| |
| |
| |
| 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 |
| |
| |
| durations: dict = {} |
| durationTotal: float = 0 |
| durationKnown: int = 0 |
| updatedAt: int = 0 |
|
|
|
|
| |
| TEAMS = {"Research", "Investments", "Growth", "ConvAI", "HyperPersonalization"} |
|
|
|
|
| |
| |
| |
| 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") |
| 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 = 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, |
| } |
|
|
|
|
| |
| |
| |
| @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) |
| |
| |
| 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_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 => |
| ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m])); |
| } |
| |
| refresh(); |
| setInterval(refresh, 15000); |
| </script> |
| </body> |
| </html> |
| """ |
|
|