Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/
.dev.vars
*.log
.DS_Store
*.env

# build outputs
workers/api/dist/
Expand Down
19 changes: 19 additions & 0 deletions data/parse_uji.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import re
import string
import sys
from dataclasses import dataclass, asdict
from pathlib import Path

Expand Down Expand Up @@ -113,7 +114,25 @@ def parse_all(path: Path = SOURCE) -> list[Sample]:


if __name__ == "__main__":
if not SOURCE.is_file() or SOURCE.stat().st_size == 0:
print(
f"parse_uji: missing or empty {SOURCE.name} at {SOURCE}\n"
"Download the UJIpenchars2 text export and save it as ujiv2.txt in the repo root.\n"
"(ujiv2.txt is gitignored; it is not committed to this repository.)",
file=sys.stderr,
)
sys.exit(1)

samples = parse_all()
if not samples:
print(
f"parse_uji: no samples parsed from {SOURCE}.\n"
"Check that the file is UTF-8 UJIpenchars2 format (WORD / NUMSTROKES / POINTS lines).\n"
"Only ASCII alphanumeric WORD labels are kept.",
file=sys.stderr,
)
sys.exit(1)

writers = sorted({s.writer for s in samples})
by_label: dict[str, int] = {}
for s in samples:
Expand Down
33 changes: 33 additions & 0 deletions frontend/analytics-dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>phoneme · analytics</title>
<link rel="stylesheet" href="./src/teacher.css" />
</head>
<body class="t-body">
<main class="t-main">
<h1 class="t-page-title">Analytics</h1>
<p class="t-page-sub">Stroke logs and session summaries for every student, across all classes.</p>

<div class="t-toolbar" id="analytics-filters" hidden>
<label class="t-label" style="flex:1;min-width:180px">
Search students
<input type="search" id="search-input" class="t-input" placeholder="Student name…" style="width:100%" />
</label>
<label class="t-label">
Filter by class
<select id="class-filter" class="t-select">
<option value="">All classes</option>
</select>
</label>
</div>

<p id="rep-msg" class="t-msg" hidden></p>
<p id="no-results" class="t-muted" hidden>No students match your search.</p>
<div id="report-root"></div>
</main>
<script type="module" src="./src/analytics-dashboard.ts"></script>
</body>
</html>
31 changes: 31 additions & 0 deletions frontend/classroom-setup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>phoneme · roster</title>
<link rel="stylesheet" href="./src/teacher.css" />
</head>
<body class="t-body">
<main class="t-main">
<h1 class="t-page-title">Roster</h1>
<p class="t-page-sub">Add a class, then enroll students. Sessions are started from the Class tab.</p>

<p id="setup-msg" class="t-msg" hidden></p>

<div class="t-card">
<h2 class="t-card-title">New class</h2>
<form id="class-form" class="t-form">
<label class="t-label">
Class name
<input id="class-name" class="t-input" type="text" required placeholder="Homeroom A" />
</label>
<button type="submit" class="t-btn">Create class</button>
</form>
</div>

<section id="classes-area"></section>
</main>
<script type="module" src="./src/classroom-setup.ts"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions frontend/dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>phoneme · classroom hub</title>
<link rel="stylesheet" href="./src/style.css" />
</head>
<body class="pad-body">
<main style="max-width: 42rem; margin: 2rem auto; padding: 0 1rem">
<h1>Classroom</h1>
<p>Teachers sign in first, then manage sessions and analytics.</p>
<ul>
<li><a href="./teacher-login.html">Teacher sign-in</a></li>
<li><a href="./session-console.html">Student sessions</a></li>
<li><a href="./classroom-setup.html">Classroom setup</a> (roster)</li>
<li><a href="./analytics-dashboard.html">Class analytics</a> (needs <code>?classId=</code>)</li>
<li><a href="./index.html">Drawing pad</a></li>
<li><a href="./visualizer.html">UJI visualizer</a></li>
</ul>
</main>
<script type="module" src="./src/dashboard-entry.ts"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,26 @@ <h1>phoneme</h1>
<label>word <input id="word" type="text" placeholder="phoneme" autocomplete="off" spellcheck="false" /></label>
<button id="play" type="button">▶ play</button>
<button id="clear" type="button">clear</button>
<a class="nav" id="nav-console" href="./session-console.html">← console</a>
<a class="nav" href="./visualizer.html">→ visualizer</a>
<a class="nav" href="./object-box.html">→ object box</a>
<button type="button" class="nav nav-btn-end" id="nav-btn-end" hidden>end session</button>
</div>
</header>
<canvas id="pad"></canvas>
</main>
<div id="pin-modal" class="pin-modal" role="dialog" aria-modal="true" aria-label="End session">
<div class="pin-modal-card">
<h2 class="pin-modal-title">End session</h2>
<p class="pin-modal-hint">Enter the session PIN to end practice and return to the console.</p>
<input type="password" inputmode="numeric" id="pin-input" autocomplete="off" maxlength="8" placeholder="· · · ·" />
<p id="pin-msg" class="pin-msg" role="status"></p>
<div class="pin-modal-actions">
<button type="button" id="pin-confirm">Confirm</button>
<button type="button" id="pin-cancel" class="secondary">Cancel</button>
</div>
</div>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
43 changes: 43 additions & 0 deletions frontend/session-console.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>phoneme · class</title>
<link rel="stylesheet" href="./src/teacher.css" />
</head>
<body class="t-body">
<main class="t-main">
<h1 class="t-page-title">Class</h1>
<p class="t-page-sub">Start a practice session for a student on this device, then open the drawing pad.</p>

<div class="t-toolbar">
<label class="t-label">
Class
<select id="class-sel" class="t-select"></select>
</label>
</div>

<p id="sess-msg" class="t-msg" hidden></p>

<!-- PIN — always shown; same PIN for every student and class -->
<div id="pin-reveal" class="t-pin-reveal" hidden>
<p class="t-pin-label">Your session PIN</p>
<p class="t-pin-digits" id="pin-reveal-digits">—</p>
<p class="t-pin-note">This PIN ends any practice session from the drawing pad. It never changes.</p>
</div>

<div class="t-card">
<h2 class="t-card-title">Roster</h2>
<ul id="roster" class="t-list"></ul>
</div>

<p style="margin-top:10px">
<a href="/index.html" class="t-muted">→ drawing pad</a> ·
<a href="/visualizer.html" class="t-muted">→ visualizer</a> ·
<a href="/object-box.html" class="t-muted">→ object box</a>
</p>
</main>
<script type="module" src="./src/session-console.ts"></script>
</body>
</html>
163 changes: 163 additions & 0 deletions frontend/src/analytics-dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { guardTeacherApp } from "./auth-guard";
import { initTeacherNav } from "./teacher-nav";

await guardTeacherApp();
initTeacherNav({ active: "analytics" });

const root = document.getElementById("report-root") as HTMLDivElement;
const msg = document.getElementById("rep-msg") as HTMLParagraphElement;
const noResults = document.getElementById("no-results") as HTMLParagraphElement;
const filters = document.getElementById("analytics-filters") as HTMLDivElement;
const searchEl = document.getElementById("search-input") as HTMLInputElement;
const classFilter = document.getElementById("class-filter") as HTMLSelectElement;

interface ClassRow { id: number; name: string; }

interface StrokeLogistics {
strokeCount: number;
goodStrokeCount: number;
orphanStrokeCount: number;
distinctWords: number;
distinctLetters: number;
sessionCount: number;
avgSessionDurationMs: number;
firstActivityAt: number | null;
lastActivityAt: number | null;
}

interface StudentReport {
studentId: number;
displayName: string;
strokeLogistics: StrokeLogistics;
visualizerReport: { summary: string; metrics: Record<string, number> };
}

function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

function fmtDuration(ms: number): string {
if (ms <= 0) return "—";
const totalSec = Math.round(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}

function fmtDate(ts: number | null): string {
if (!ts) return "—";
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}

function stat(value: string | number, label: string): string {
return `<div class="t-stat"><strong>${value}</strong><span>${escHtml(label)}</span></div>`;
}

function renderStudent(s: StudentReport, classId: number): string {
const sl = s.strokeLogistics;
const accuracy = sl.strokeCount > 0
? Math.round((sl.goodStrokeCount / sl.strokeCount) * 100) + "%"
: "—";
return `
<div class="t-card student-card" data-class-id="${classId}" data-name="${escHtml(s.displayName.toLowerCase())}">
<h3 class="t-card-title" style="margin-bottom:10px">${escHtml(s.displayName)}</h3>
<div class="t-stat-grid">
${stat(sl.strokeCount, "total strokes")}
${stat(accuracy, "accuracy")}
${stat(sl.distinctWords, "words")}
${stat(sl.distinctLetters, "letters")}
${stat(sl.sessionCount, "sessions")}
${stat(fmtDuration(sl.avgSessionDurationMs), "avg session")}
${stat(fmtDate(sl.firstActivityAt), "first activity")}
${stat(fmtDate(sl.lastActivityAt), "last activity")}
</div>
</div>`;
}

function applyFilter(): void {
const query = searchEl.value.trim().toLowerCase();
const classId = classFilter.value;

const cards = root.querySelectorAll<HTMLElement>(".student-card");
const sections = root.querySelectorAll<HTMLElement>(".class-section");
let anyVisible = false;

for (const card of cards) {
const nameMatch = !query || (card.dataset.name ?? "").includes(query);
const classMatch = !classId || card.dataset.classId === classId;
const visible = nameMatch && classMatch;
card.hidden = !visible;
if (visible) anyVisible = true;
}

// Hide class sections that have no visible students
for (const section of sections) {
const hasVisible = [...section.querySelectorAll<HTMLElement>(".student-card")]
.some((c) => !c.hidden);
section.hidden = !hasVisible;
}

noResults.hidden = anyVisible;
}

// ── Fetch data ───────────────────────────────────────────────────────────────

async function apiGet<T>(path: string): Promise<{ ok: boolean; body: T }> {
const r = await fetch(path, { credentials: "include" });
return { ok: r.ok, body: (await r.json()) as T };
}

const { ok: classOk, body: classBody } = await apiGet<{ classes?: ClassRow[] }>("/api/teacher/classes");

if (!classOk || !classBody.classes?.length) {
msg.textContent = classOk ? "No classes found. Create one in the Roster tab." : "Failed to load classes.";
msg.removeAttribute("hidden");
} else {
const classes = classBody.classes;

// Populate class filter dropdown
for (const c of classes) {
const opt = document.createElement("option");
opt.value = String(c.id);
opt.textContent = c.name;
classFilter.appendChild(opt);
}

// Fetch all class reports in parallel
const reports = await Promise.all(
classes.map((c) =>
apiGet<{ students?: StudentReport[] }>(`/api/teacher/classes/${c.id}/report`),
),
);

// Render
const parts: string[] = [];
let totalStudents = 0;

for (let i = 0; i < classes.length; i++) {
const c = classes[i]!;
const rep = reports[i]!;
const students = rep.body.students ?? [];
totalStudents += students.length;

parts.push(`<section class="class-section" data-class-id="${c.id}">`);
parts.push(`<h2 class="t-section-title">${escHtml(c.name)} <span class="t-muted" style="font-size:14px;font-family:Inter,sans-serif">(${students.length} student${students.length === 1 ? "" : "s"})</span></h2>`);

if (students.length === 0) {
parts.push(`<p class="t-muted">No students in this class yet.</p>`);
} else {
for (const s of students) {
parts.push(renderStudent(s, c.id));
}
}
parts.push(`</section>`);
}

root.innerHTML = parts.join("");

if (totalStudents > 0) {
filters.removeAttribute("hidden");
searchEl.addEventListener("input", applyFilter);
classFilter.addEventListener("change", applyFilter);
}
}
11 changes: 11 additions & 0 deletions frontend/src/auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const LOGIN = "/teacher-login.html";

/** Require a signed-in teacher for every app page except the login screen. */
export async function guardTeacherApp(): Promise<void> {
const path = window.location.pathname;
if (path === LOGIN || path.endsWith("/teacher-login.html")) return;
const r = await fetch("/api/auth/me", { credentials: "include" });
if (r.ok) return;
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.replace(`${LOGIN}?next=${next}`);
}
Loading