Spaces:
Running
Running
taboola commited on
Commit ·
1acff4f
1
Parent(s): 2572915
restore demo data, remove category breakdown
Browse files- chatkit/Dockerfile +8 -4
- chatkit/backend/app/db.py +10 -20
- chatkit/backend/app/main.py +11 -11
- chatkit/entrypoint.sh +17 -0
- chatkit/frontend/src/App.tsx +1 -1
- chatkit/frontend/src/components/StudentDashboardPage.tsx +0 -29
- chatkit/frontend/src/components/TeacherDashboard.tsx +145 -181
- chatkit/frontend/src/components/step2/AnswerGridEditor.tsx +7 -2
chatkit/Dockerfile
CHANGED
|
@@ -45,9 +45,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|
| 45 |
# Copy backend code
|
| 46 |
COPY --chown=user backend/app ./app
|
| 47 |
|
| 48 |
-
# Copy Alembic migrations + config — needed at runtime because the
|
| 49 |
-
# applies pending migrations
|
| 50 |
-
#
|
| 51 |
COPY --chown=user backend/alembic ./alembic
|
| 52 |
COPY --chown=user backend/alembic.ini ./alembic.ini
|
| 53 |
|
|
@@ -60,6 +60,10 @@ COPY --from=frontend-builder --chown=user /app/frontend/dist ./static
|
|
| 60 |
# Copy report template HTML file
|
| 61 |
COPY --chown=user backend/static/report-template.html ./static/report-template.html
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
# Expose port (HF Spaces uses 7860)
|
| 64 |
EXPOSE 7860
|
| 65 |
|
|
@@ -68,4 +72,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
| 68 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 69 |
|
| 70 |
# Run the server
|
| 71 |
-
CMD ["
|
|
|
|
| 45 |
# Copy backend code
|
| 46 |
COPY --chown=user backend/app ./app
|
| 47 |
|
| 48 |
+
# Copy Alembic migrations + config — needed at runtime because the
|
| 49 |
+
# entrypoint applies pending migrations before uvicorn starts (there's no
|
| 50 |
+
# separate deploy step that runs `alembic upgrade head`).
|
| 51 |
COPY --chown=user backend/alembic ./alembic
|
| 52 |
COPY --chown=user backend/alembic.ini ./alembic.ini
|
| 53 |
|
|
|
|
| 60 |
# Copy report template HTML file
|
| 61 |
COPY --chown=user backend/static/report-template.html ./static/report-template.html
|
| 62 |
|
| 63 |
+
# Entrypoint: runs pending migrations (Supabase only) as their own process,
|
| 64 |
+
# then execs uvicorn — see entrypoint.sh for why this isn't done in-process.
|
| 65 |
+
COPY --chown=user entrypoint.sh ./entrypoint.sh
|
| 66 |
+
|
| 67 |
# Expose port (HF Spaces uses 7860)
|
| 68 |
EXPOSE 7860
|
| 69 |
|
|
|
|
| 72 |
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')"
|
| 73 |
|
| 74 |
# Run the server
|
| 75 |
+
CMD ["./entrypoint.sh"]
|
chatkit/backend/app/db.py
CHANGED
|
@@ -126,23 +126,13 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
|
| 126 |
raise
|
| 127 |
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
`asyncio.run(...)` for the async engine) — call via `asyncio.to_thread`
|
| 140 |
-
from async code, never await it directly.
|
| 141 |
-
"""
|
| 142 |
-
from alembic import command
|
| 143 |
-
from alembic.config import Config
|
| 144 |
-
|
| 145 |
-
backend_dir = Path(__file__).parent.parent
|
| 146 |
-
cfg = Config(str(backend_dir / "alembic.ini"))
|
| 147 |
-
cfg.set_main_option("script_location", str(backend_dir / "alembic"))
|
| 148 |
-
command.upgrade(cfg, "head")
|
|
|
|
| 126 |
raise
|
| 127 |
|
| 128 |
|
| 129 |
+
# Pending migrations are applied by a separate `alembic upgrade head`
|
| 130 |
+
# process, run by the Docker container's entrypoint before uvicorn starts
|
| 131 |
+
# (see Dockerfile) — not from inside this app's process. An in-process
|
| 132 |
+
# version of this (via asyncio.to_thread) hung indefinitely against
|
| 133 |
+
# production Postgres instead of failing fast: it shared this module's
|
| 134 |
+
# cached async engine (bound to the main thread's event loop) with a
|
| 135 |
+
# second thread running its own separate event loop for alembic's internal
|
| 136 |
+
# asyncio.run() call, which async engines/connection pools aren't safe to
|
| 137 |
+
# do. A standalone `alembic` CLI invocation as its own OS process sidesteps
|
| 138 |
+
# this entirely — no shared engine, no shared event loop.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatkit/backend/app/main.py
CHANGED
|
@@ -76,7 +76,7 @@ from .auth import (
|
|
| 76 |
from .crypto import encrypt
|
| 77 |
from .student_credentials import generate_unique_username, generate_password
|
| 78 |
from .admin import router as admin_router
|
| 79 |
-
from .db import assert_production_db_config
|
| 80 |
from .file_processor import process_uploaded_files
|
| 81 |
from .answer_grid import (
|
| 82 |
AnswerGrid,
|
|
@@ -104,17 +104,17 @@ INSIGHTS_QUIZ_WINDOW = 8
|
|
| 104 |
|
| 105 |
@asynccontextmanager
|
| 106 |
async def lifespan(app: FastAPI):
|
| 107 |
-
"""Initialize database on startup.
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
assert_production_db_config()
|
| 111 |
-
# No separate deploy step runs `alembic upgrade head` against production,
|
| 112 |
-
# and whoever redeploys may not have direct DB access to run it by hand
|
| 113 |
-
# — so apply any pending migrations here instead. Idempotent (a no-op
|
| 114 |
-
# once already current), so safe on every boot. SQLite dev/test doesn't
|
| 115 |
-
# use migrations at all (init_database() below just does create_all).
|
| 116 |
-
if get_settings().database_provider == "supabase":
|
| 117 |
-
await asyncio.to_thread(run_pending_migrations)
|
| 118 |
await init_database()
|
| 119 |
yield
|
| 120 |
|
|
|
|
| 76 |
from .crypto import encrypt
|
| 77 |
from .student_credentials import generate_unique_username, generate_password
|
| 78 |
from .admin import router as admin_router
|
| 79 |
+
from .db import assert_production_db_config
|
| 80 |
from .file_processor import process_uploaded_files
|
| 81 |
from .answer_grid import (
|
| 82 |
AnswerGrid,
|
|
|
|
| 104 |
|
| 105 |
@asynccontextmanager
|
| 106 |
async def lifespan(app: FastAPI):
|
| 107 |
+
"""Initialize database on startup.
|
| 108 |
+
|
| 109 |
+
Pending Alembic migrations are applied by the container's entrypoint
|
| 110 |
+
(see Dockerfile) as a separate `alembic upgrade head` process *before*
|
| 111 |
+
uvicorn starts — not from here. An earlier version tried to run it from
|
| 112 |
+
inside this async lifespan via asyncio.to_thread, but that shares the
|
| 113 |
+
app's cached async engine (bound to this process's main event loop)
|
| 114 |
+
across a second thread's separate event loop, which hung indefinitely
|
| 115 |
+
against Postgres instead of failing fast.
|
| 116 |
+
"""
|
| 117 |
assert_production_db_config()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
await init_database()
|
| 119 |
yield
|
| 120 |
|
chatkit/entrypoint.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
# Applies pending Alembic migrations (Postgres/Supabase only — SQLite dev
|
| 3 |
+
# doesn't use migrations, see database.py's init_database) as a standalone
|
| 4 |
+
# process *before* uvicorn starts, then execs into uvicorn. Deliberately not
|
| 5 |
+
# done from inside the app's own async lifespan: an earlier attempt at that
|
| 6 |
+
# shared the app's cached async engine across a second thread's separate
|
| 7 |
+
# event loop (via asyncio.to_thread), which hung indefinitely against
|
| 8 |
+
# production Postgres instead of failing fast. A separate OS process has no
|
| 9 |
+
# such shared state.
|
| 10 |
+
set -e
|
| 11 |
+
|
| 12 |
+
if [ "$DATABASE_PROVIDER" = "supabase" ]; then
|
| 13 |
+
echo "Running database migrations..."
|
| 14 |
+
alembic upgrade head
|
| 15 |
+
fi
|
| 16 |
+
|
| 17 |
+
exec uvicorn app.main:app --host 0.0.0.0 --port 7860
|
chatkit/frontend/src/App.tsx
CHANGED
|
@@ -362,7 +362,7 @@ ${doneReports
|
|
| 362 |
|
| 363 |
{visitedViews.has("dashboard") && (
|
| 364 |
<div className="pt-20" style={{ display: currentView === "dashboard" ? "block" : "none" }}>
|
| 365 |
-
<TeacherDashboard active={currentView === "dashboard"} />
|
| 366 |
</div>
|
| 367 |
)}
|
| 368 |
|
|
|
|
| 362 |
|
| 363 |
{visitedViews.has("dashboard") && (
|
| 364 |
<div className="pt-20" style={{ display: currentView === "dashboard" ? "block" : "none" }}>
|
| 365 |
+
<TeacherDashboard active={currentView === "dashboard"} userEmail={user?.email ?? ""} />
|
| 366 |
</div>
|
| 367 |
)}
|
| 368 |
|
chatkit/frontend/src/components/StudentDashboardPage.tsx
CHANGED
|
@@ -3,11 +3,9 @@ import { apiGet, clearToken, getToken } from "../lib/api";
|
|
| 3 |
import { API_BASE_URL } from "../lib/config";
|
| 4 |
import { CLASSLENS_ICON } from "../lib/icon";
|
| 5 |
import {
|
| 6 |
-
CategoryBreakdown,
|
| 7 |
LineChart,
|
| 8 |
RawFileLink,
|
| 9 |
ScoreBadge,
|
| 10 |
-
type CategoryHistoryEntry,
|
| 11 |
type StudentDetail,
|
| 12 |
} from "./TeacherDashboard";
|
| 13 |
|
|
@@ -66,9 +64,6 @@ export function StudentDashboardPage({ student, onLogout }: { student: StudentSe
|
|
| 66 |
const [error, setError] = useState("");
|
| 67 |
const [quizzes, setQuizzes] = useState<PracticeQuizItem[]>([]);
|
| 68 |
const [assessmentView, setAssessmentView] = useState<"graph" | "table">("graph");
|
| 69 |
-
const [categoryHistory, setCategoryHistory] = useState<Record<string, CategoryHistoryEntry[]> | null>(null);
|
| 70 |
-
const [categoryHistoryLoading, setCategoryHistoryLoading] = useState(true);
|
| 71 |
-
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
| 72 |
|
| 73 |
useEffect(() => {
|
| 74 |
apiGet<StudentDetail>("/api/student/dashboard")
|
|
@@ -78,10 +73,6 @@ export function StudentDashboardPage({ student, onLogout }: { student: StudentSe
|
|
| 78 |
apiGet<{ practice_quizzes: PracticeQuizItem[] }>("/api/student/practice-quizzes")
|
| 79 |
.then((r) => setQuizzes(r.practice_quizzes))
|
| 80 |
.catch(() => {});
|
| 81 |
-
apiGet<{ categories: Record<string, CategoryHistoryEntry[]> }>("/api/student/category-history")
|
| 82 |
-
.then((r) => setCategoryHistory(r.categories))
|
| 83 |
-
.catch(() => setCategoryHistory({}))
|
| 84 |
-
.finally(() => setCategoryHistoryLoading(false));
|
| 85 |
}, []);
|
| 86 |
|
| 87 |
if (loading) {
|
|
@@ -160,26 +151,6 @@ export function StudentDashboardPage({ student, onLogout }: { student: StudentSe
|
|
| 160 |
)}
|
| 161 |
</div>
|
| 162 |
|
| 163 |
-
{(categoryHistoryLoading || (categoryHistory && Object.keys(categoryHistory).length > 0)) && (
|
| 164 |
-
<div className="card p-6">
|
| 165 |
-
<h3 className="font-sans text-xs font-bold uppercase tracking-widest text-[var(--color-text)] mb-5 flex items-center gap-1.5">
|
| 166 |
-
<img src="/avgscores.svg" alt="" className="w-4 h-4 shrink-0 object-contain" />
|
| 167 |
-
Category Breakdown
|
| 168 |
-
</h3>
|
| 169 |
-
{categoryHistoryLoading ? (
|
| 170 |
-
<div className="h-24 flex items-center justify-center">
|
| 171 |
-
<div className="w-5 h-5 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
| 172 |
-
</div>
|
| 173 |
-
) : (
|
| 174 |
-
<CategoryBreakdown
|
| 175 |
-
data={categoryHistory ?? {}}
|
| 176 |
-
expanded={expandedCategory}
|
| 177 |
-
onToggle={(cat) => setExpandedCategory((prev) => (prev === cat ? null : cat))}
|
| 178 |
-
/>
|
| 179 |
-
)}
|
| 180 |
-
</div>
|
| 181 |
-
)}
|
| 182 |
-
|
| 183 |
<div className="card p-6">
|
| 184 |
<div className="flex items-center justify-between mb-5 shrink-0">
|
| 185 |
<h3 className="font-sans text-xs font-bold uppercase tracking-widest text-[var(--color-text)] flex items-center gap-1.5">
|
|
|
|
| 3 |
import { API_BASE_URL } from "../lib/config";
|
| 4 |
import { CLASSLENS_ICON } from "../lib/icon";
|
| 5 |
import {
|
|
|
|
| 6 |
LineChart,
|
| 7 |
RawFileLink,
|
| 8 |
ScoreBadge,
|
|
|
|
| 9 |
type StudentDetail,
|
| 10 |
} from "./TeacherDashboard";
|
| 11 |
|
|
|
|
| 64 |
const [error, setError] = useState("");
|
| 65 |
const [quizzes, setQuizzes] = useState<PracticeQuizItem[]>([]);
|
| 66 |
const [assessmentView, setAssessmentView] = useState<"graph" | "table">("graph");
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
useEffect(() => {
|
| 69 |
apiGet<StudentDetail>("/api/student/dashboard")
|
|
|
|
| 73 |
apiGet<{ practice_quizzes: PracticeQuizItem[] }>("/api/student/practice-quizzes")
|
| 74 |
.then((r) => setQuizzes(r.practice_quizzes))
|
| 75 |
.catch(() => {});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}, []);
|
| 77 |
|
| 78 |
if (loading) {
|
|
|
|
| 151 |
)}
|
| 152 |
</div>
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
<div className="card p-6">
|
| 155 |
<div className="flex items-center justify-between mb-5 shrink-0">
|
| 156 |
<h3 className="font-sans text-xs font-bold uppercase tracking-widest text-[var(--color-text)] flex items-center gap-1.5">
|
chatkit/frontend/src/components/TeacherDashboard.tsx
CHANGED
|
@@ -180,15 +180,6 @@ export interface TimelineEntry {
|
|
| 180 |
report_id: number | null;
|
| 181 |
}
|
| 182 |
|
| 183 |
-
export interface CategoryHistoryEntry {
|
| 184 |
-
quiz_id: number;
|
| 185 |
-
quiz_title: string;
|
| 186 |
-
quiz_date: string;
|
| 187 |
-
correct: number;
|
| 188 |
-
total: number;
|
| 189 |
-
accuracy: number;
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
export interface StudentDetail {
|
| 193 |
student: { id: number; name: string; email: string };
|
| 194 |
timeline: TimelineEntry[];
|
|
@@ -199,6 +190,124 @@ export interface StudentDetail {
|
|
| 199 |
ai_study_recommendations: string;
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
// ── Constants ─────────────────────────────────────────────────────────────────
|
| 203 |
|
| 204 |
const AVATAR_COLORS = [
|
|
@@ -267,134 +376,6 @@ export function ScoreBadge({ score }: { score: number }) {
|
|
| 267 |
);
|
| 268 |
}
|
| 269 |
|
| 270 |
-
// Same 4-tier score scale as ScoreBadge/scoreColor, as a fill class for a bar.
|
| 271 |
-
function scoreFillClass(score: number): string {
|
| 272 |
-
if (score < 50) return "bg-red-500";
|
| 273 |
-
if (score < 65) return "bg-orange-400";
|
| 274 |
-
if (score < 80) return "bg-amber-500";
|
| 275 |
-
return "bg-emerald-500";
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
// Tiny inline trend line for one category's accuracy across its quizzes —
|
| 279 |
-
// same minimal, axis-less style as the hover crosshair dots in LineChart.
|
| 280 |
-
function CategorySparkline({ entries }: { entries: CategoryHistoryEntry[] }) {
|
| 281 |
-
if (entries.length < 2) return null;
|
| 282 |
-
const W = 200, H = 40, PAD = 5;
|
| 283 |
-
const toX = (i: number) => PAD + (i / (entries.length - 1)) * (W - PAD * 2);
|
| 284 |
-
const toY = (v: number) => H - PAD - (v / 100) * (H - PAD * 2);
|
| 285 |
-
const pts = entries.map((e, i) => `${toX(i)},${toY(e.accuracy)}`).join(" ");
|
| 286 |
-
return (
|
| 287 |
-
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-10" preserveAspectRatio="none" aria-hidden="true">
|
| 288 |
-
<polyline points={pts} fill="none" stroke="var(--color-primary)" strokeWidth="1.5"
|
| 289 |
-
strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
| 290 |
-
{entries.map((e, i) => (
|
| 291 |
-
<circle key={e.quiz_id} cx={toX(i)} cy={toY(e.accuracy)} r="2.5" fill="var(--color-primary)" />
|
| 292 |
-
))}
|
| 293 |
-
</svg>
|
| 294 |
-
);
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
// Per-category accuracy breakdown under Performance History. One row per
|
| 298 |
-
// category (worst-accuracy-first, so what needs attention surfaces without
|
| 299 |
-
// scanning), a compact bar for the latest accuracy, and an explicit ▲/▼ delta
|
| 300 |
-
// vs. that category's previous quiz — never color alone. Click a row to
|
| 301 |
-
// expand a small trend line + the underlying per-quiz numbers.
|
| 302 |
-
export function CategoryBreakdown({
|
| 303 |
-
data, expanded, onToggle,
|
| 304 |
-
}: {
|
| 305 |
-
data: Record<string, CategoryHistoryEntry[]>;
|
| 306 |
-
expanded: string | null;
|
| 307 |
-
onToggle: (category: string) => void;
|
| 308 |
-
}) {
|
| 309 |
-
// One-time ease-in per data set: bars start at 0 and ease up to their real
|
| 310 |
-
// value the first time this student's breakdown appears, instead of just
|
| 311 |
-
// snapping straight to the final width.
|
| 312 |
-
const [animated, setAnimated] = useState(false);
|
| 313 |
-
useEffect(() => {
|
| 314 |
-
setAnimated(false);
|
| 315 |
-
const id = requestAnimationFrame(() => requestAnimationFrame(() => setAnimated(true)));
|
| 316 |
-
return () => cancelAnimationFrame(id);
|
| 317 |
-
}, [data]);
|
| 318 |
-
|
| 319 |
-
const rows = Object.entries(data)
|
| 320 |
-
.filter(([, entries]) => entries.length > 0)
|
| 321 |
-
.map(([category, entries]) => {
|
| 322 |
-
const latest = entries[entries.length - 1];
|
| 323 |
-
const prev = entries.length > 1 ? entries[entries.length - 2] : null;
|
| 324 |
-
const delta = prev ? Math.round(latest.accuracy - prev.accuracy) : null;
|
| 325 |
-
return { category, entries, latest, delta };
|
| 326 |
-
})
|
| 327 |
-
.sort((a, b) => a.latest.accuracy - b.latest.accuracy);
|
| 328 |
-
|
| 329 |
-
if (rows.length === 0) {
|
| 330 |
-
return (
|
| 331 |
-
<div className="h-40 flex items-center justify-center">
|
| 332 |
-
<p className="text-xs text-[var(--color-text-muted)] italic">No categorized data yet.</p>
|
| 333 |
-
</div>
|
| 334 |
-
);
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
return (
|
| 338 |
-
<div className="space-y-1.5">
|
| 339 |
-
{rows.map(({ category, entries, latest, delta }, i) => {
|
| 340 |
-
const isOpen = expanded === category;
|
| 341 |
-
return (
|
| 342 |
-
<div key={category} className="rounded-lg border border-[var(--color-border)] overflow-hidden">
|
| 343 |
-
<button
|
| 344 |
-
onClick={() => onToggle(category)}
|
| 345 |
-
aria-expanded={isOpen}
|
| 346 |
-
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-[var(--color-border)]/20 transition-colors"
|
| 347 |
-
>
|
| 348 |
-
<span className={`text-[10px] text-[var(--color-text-muted)] shrink-0 transition-transform duration-150 ${isOpen ? "rotate-90" : ""}`}>▸</span>
|
| 349 |
-
<span className="text-xs font-medium text-[var(--color-text)] w-24 shrink-0 truncate" title={category}>{category}</span>
|
| 350 |
-
<div className="flex-1 h-2 rounded-full bg-[var(--color-border)]/40 overflow-hidden">
|
| 351 |
-
<div
|
| 352 |
-
className={`h-full rounded-full ease-out ${scoreFillClass(latest.accuracy)}`}
|
| 353 |
-
style={{
|
| 354 |
-
width: animated ? `${Math.max(4, latest.accuracy)}%` : "0%",
|
| 355 |
-
transitionProperty: "width",
|
| 356 |
-
transitionDuration: "700ms",
|
| 357 |
-
transitionDelay: `${i * 70}ms`,
|
| 358 |
-
}}
|
| 359 |
-
/>
|
| 360 |
-
</div>
|
| 361 |
-
<span className="text-xs font-semibold text-[var(--color-text)] w-9 text-right tabular-nums shrink-0">
|
| 362 |
-
{Math.round(latest.accuracy)}%
|
| 363 |
-
</span>
|
| 364 |
-
<span
|
| 365 |
-
className={`text-[10px] font-medium w-14 text-right tabular-nums shrink-0 ${
|
| 366 |
-
delta === null || delta === 0
|
| 367 |
-
? "text-[var(--color-text-muted)]"
|
| 368 |
-
: delta > 0
|
| 369 |
-
? "text-emerald-500"
|
| 370 |
-
: "text-red-500"
|
| 371 |
-
}`}
|
| 372 |
-
>
|
| 373 |
-
{delta === null ? "首次" : delta > 0 ? `▲ ${delta}%` : delta < 0 ? `▼ ${Math.abs(delta)}%` : "— 0%"}
|
| 374 |
-
</span>
|
| 375 |
-
</button>
|
| 376 |
-
{isOpen && (
|
| 377 |
-
<div className="px-3 pb-3 pt-1 border-t border-[var(--color-border)] bg-[var(--color-border)]/10">
|
| 378 |
-
<CategorySparkline entries={entries} />
|
| 379 |
-
<ul className="mt-1 space-y-1">
|
| 380 |
-
{entries.slice().reverse().map((e) => (
|
| 381 |
-
<li key={e.quiz_id} className="flex items-center justify-between text-[11px] text-[var(--color-text-muted)]">
|
| 382 |
-
<span className="truncate">{e.quiz_title}</span>
|
| 383 |
-
<span className="tabular-nums font-medium text-[var(--color-text)] shrink-0 ml-2">
|
| 384 |
-
{Math.round(e.accuracy)}% ({e.correct}/{e.total})
|
| 385 |
-
</span>
|
| 386 |
-
</li>
|
| 387 |
-
))}
|
| 388 |
-
</ul>
|
| 389 |
-
</div>
|
| 390 |
-
)}
|
| 391 |
-
</div>
|
| 392 |
-
);
|
| 393 |
-
})}
|
| 394 |
-
</div>
|
| 395 |
-
);
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
export function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
| 399 |
return (
|
| 400 |
<button
|
|
@@ -1123,14 +1104,17 @@ function QuestionGenPanel({ studentId, initialCats, initialTagsByCat }: { studen
|
|
| 1123 |
|
| 1124 |
// ── Main component ────────────────────────────────────────────────────────────
|
| 1125 |
|
| 1126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 1128 |
const [search, setSearch] = useState("");
|
| 1129 |
const [selected, setSelected] = useState<StudentListItem | null>(null);
|
| 1130 |
const [assessmentView, setAssessmentView] = useState<"graph" | "table">("graph");
|
| 1131 |
-
const [categoryHistory, setCategoryHistory] = useState<Record<string, CategoryHistoryEntry[]> | null>(null);
|
| 1132 |
-
const [categoryHistoryLoading, setCategoryHistoryLoading] = useState(false);
|
| 1133 |
-
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
| 1134 |
const [autoSelectWeak, setAutoSelectWeak] = useState(true);
|
| 1135 |
|
| 1136 |
const [students, setStudents] = useState<StudentListItem[]>([]);
|
|
@@ -1167,10 +1151,27 @@ export function TeacherDashboard({ active = true }: { active?: boolean }) {
|
|
| 1167 |
// (and wait) on every student selection. Still re-fetched here (even
|
| 1168 |
// when cached) to pick up anything saved since the last visit.
|
| 1169 |
const d = await apiGet<StudentDetail>(`/api/students/${student.id}`);
|
| 1170 |
-
|
| 1171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
} catch {
|
| 1173 |
-
if (!cached)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
} finally {
|
| 1175 |
setDetailLoading(false);
|
| 1176 |
}
|
|
@@ -1221,26 +1222,9 @@ export function TeacherDashboard({ active = true }: { active?: boolean }) {
|
|
| 1221 |
|
| 1222 |
const handleSelectStudent = (student: StudentListItem) => {
|
| 1223 |
setSelected(student);
|
| 1224 |
-
setSelectedCategory(null);
|
| 1225 |
refreshDetail(student);
|
| 1226 |
};
|
| 1227 |
|
| 1228 |
-
// Category Breakdown is its own always-visible card now (not a tab buried
|
| 1229 |
-
// inside Performance History), so it fetches as soon as a student is
|
| 1230 |
-
// selected instead of waiting for a click.
|
| 1231 |
-
useEffect(() => {
|
| 1232 |
-
if (!selected) {
|
| 1233 |
-
setCategoryHistory(null);
|
| 1234 |
-
return;
|
| 1235 |
-
}
|
| 1236 |
-
setCategoryHistory(null);
|
| 1237 |
-
setCategoryHistoryLoading(true);
|
| 1238 |
-
apiGet<{ categories: Record<string, CategoryHistoryEntry[]> }>(`/api/students/${selected.id}/category-history`)
|
| 1239 |
-
.then((r) => setCategoryHistory(r.categories))
|
| 1240 |
-
.catch(() => setCategoryHistory({}))
|
| 1241 |
-
.finally(() => setCategoryHistoryLoading(false));
|
| 1242 |
-
}, [selected?.id]);
|
| 1243 |
-
|
| 1244 |
// Derived metrics from real timeline data
|
| 1245 |
const timeline = detail?.timeline ?? [];
|
| 1246 |
const scores = timeline.map((t) => Math.round(t.score));
|
|
@@ -1441,26 +1425,6 @@ export function TeacherDashboard({ active = true }: { active?: boolean }) {
|
|
| 1441 |
</div>
|
| 1442 |
</div>
|
| 1443 |
|
| 1444 |
-
{/* ── Category Breakdown ─────────────────────────────────────── */}
|
| 1445 |
-
{(categoryHistoryLoading || (categoryHistory && Object.keys(categoryHistory).length > 0)) && (
|
| 1446 |
-
<div className="card p-6">
|
| 1447 |
-
<h3 className="font-sans text-xs font-bold uppercase tracking-widest text-[var(--color-text)] mb-5 flex items-center gap-1.5">
|
| 1448 |
-
<img src="/avgscores.svg" alt="" className="w-4 h-4 shrink-0 object-contain" />
|
| 1449 |
-
Category Breakdown
|
| 1450 |
-
</h3>
|
| 1451 |
-
{categoryHistoryLoading ? (
|
| 1452 |
-
<div className="h-24 flex items-center justify-center">
|
| 1453 |
-
<div className="w-5 h-5 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
| 1454 |
-
</div>
|
| 1455 |
-
) : (
|
| 1456 |
-
<CategoryBreakdown
|
| 1457 |
-
data={categoryHistory ?? {}}
|
| 1458 |
-
expanded={selectedCategory}
|
| 1459 |
-
onToggle={(cat) => setSelectedCategory((prev) => (prev === cat ? null : cat))}
|
| 1460 |
-
/>
|
| 1461 |
-
)}
|
| 1462 |
-
</div>
|
| 1463 |
-
)}
|
| 1464 |
|
| 1465 |
{/* ── Performance History ────────────────────────────────────── */}
|
| 1466 |
<div className="card p-6">
|
|
|
|
| 180 |
report_id: number | null;
|
| 181 |
}
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
export interface StudentDetail {
|
| 184 |
student: { id: number; name: string; email: string };
|
| 185 |
timeline: TimelineEntry[];
|
|
|
|
| 190 |
ai_study_recommendations: string;
|
| 191 |
}
|
| 192 |
|
| 193 |
+
// ── Demo mock data (client-side only — no backend, no DB write) ────────────────
|
| 194 |
+
// TODO(demo): remove once real report data exists for these students. Keyed
|
| 195 |
+
// by exact student name; whenever one of these names is selected, the
|
| 196 |
+
// dashboard is displayed from this hand-written data as a *baseline* — real
|
| 197 |
+
// quiz history merges in on top and takes over automatically as soon as it
|
| 198 |
+
// exists, so this is temporary scaffolding, not a permanent replacement.
|
| 199 |
+
|
| 200 |
+
const MOCK_QUIZ_TITLES_DAYS_AGO: [string, number][] = [
|
| 201 |
+
["Unit 3 Quiz", 42], ["Unit 4 Quiz", 35], ["Midterm", 28],
|
| 202 |
+
["Unit 5 Quiz", 21], ["Unit 6 Quiz", 14], ["Unit 7 Practice Test", 7],
|
| 203 |
+
];
|
| 204 |
+
|
| 205 |
+
const MOCK_PROFILES: Record<string, { scores: number[]; summary: string; weaknesses: string; study_recommendations: string }> = {
|
| 206 |
+
"柯主恩": {
|
| 207 |
+
scores: [88, 91, 90, 94, 93, 96],
|
| 208 |
+
summary: "過去六次測驗表現持續穩定進步,平均 92 分,成績從 88 分成長至 96 分,是班上表現最穩定的學生之一。近三次測驗皆維持在 90 分以上,顯示對整體英文能力已有紮實掌握。",
|
| 209 |
+
weaknesses: "整體弱點極少,主要集中在介系詞片語的慣用搭配,於六次測驗中有兩次出現相關錯誤,屬於偶發性失誤而非系統性問題。部分閱讀理解題在需要推論作者語氣或態度時,答題速度會明顯變慢。",
|
| 210 |
+
study_recommendations: "建議針對介系詞片語進行慣用搭配的整理與複習,特別留意動詞與介系詞的固定搭配組合,減少偶發性失誤。可嘗試更高難度的閱讀理解題型,加強對語氣、態度等推論性題目的敏感度,為進階閱讀測驗做準備。",
|
| 211 |
+
},
|
| 212 |
+
"梁祐邦": {
|
| 213 |
+
scores: [58, 62, 60, 65, 63, 69],
|
| 214 |
+
summary: "過去六次測驗平均 62.8 分,整體呈現緩步上升趨勢,最近一次測驗達到 69 分,為六次以來最高分。雖然進步趨勢明確,但仍有將近四成的失分集中在文法結構相關題型。",
|
| 215 |
+
weaknesses: "過去簡單式與現在完成式的判斷是最主要的弱點,六次測驗中有五次出現混淆情形,尤其在題目未明確標示時間副詞時,經常誤選現在完成式。此弱點具高度持續性,是目前影響分數最大的單一因素。",
|
| 216 |
+
study_recommendations: "建議透過時間軸圖表具體對照過去簡單式與現在完成式的使用情境,聚焦在「動作是否與現在有關聯」這個核心判斷依據,而非死記時間副詞。可每週安排固定的混合式練習題,交叉出現兩種時態,訓練快速辨識的直覺反應。",
|
| 217 |
+
},
|
| 218 |
+
"江亮吟": {
|
| 219 |
+
scores: [70, 68, 74, 72, 78, 80],
|
| 220 |
+
summary: "過去六次測驗平均 73.7 分,成績呈現穩定上升趨勢,從 70 分進步到 80 分,近三次測驗表現尤其亮眼。整體弱點集中且明確,具備透過針對性練習快速提升的潛力。",
|
| 221 |
+
weaknesses: "關係代名詞子句的使用在六次測驗中有四次出現錯誤,主要問題在於 who/which 的選用判斷,以及受詞關係代名詞可省略情況的判斷不穩定。此弱點在複雜句型題目中尤其明顯,經常導致連鎖性的理解錯誤。",
|
| 222 |
+
study_recommendations: "建議針對關係代名詞進行句子合併與改寫的專項練習,先從先行詞是人或物的判斷開始建立直覺,再進階練習省略規則的例外情況。可搭配閱讀理解文章中的長句拆解練習,強化對關係子句在段落中所扮演角色的理解。",
|
| 223 |
+
},
|
| 224 |
+
"田瑜婕": {
|
| 225 |
+
scores: [64, 67, 66, 71, 70, 75],
|
| 226 |
+
summary: "過去六次測驗平均 68.8 分,整體趨勢緩步上升,最近一次測驗達到 75 分,較六次以來最低分進步了 11 分。字彙相關題型是目前分數成長的主要瓶頸。",
|
| 227 |
+
weaknesses: "需要情境字彙推斷的題目正確率偏低,六次測驗中有四次此類題型失分,顯示在缺乏直接字義提示時,從上下文判斷字義的能力仍待加強。動詞與動詞片語的搭配也是持續性弱點,尤其容易混淆語意相近但用法不同的片語動詞。",
|
| 228 |
+
study_recommendations: "建議加強精讀短文練習,刻意選擇包含生字的段落,訓練從上下文線索(如同義詞、對比詞)推斷字義的策略性思考。可整理常見動詞片語做主題式複習,透過例句比較釐清語意相近片語之間的用法差異。",
|
| 229 |
+
},
|
| 230 |
+
"蕭仁昊": {
|
| 231 |
+
scores: [73, 71, 76, 74, 79, 77],
|
| 232 |
+
summary: "過去六次測驗平均 75.0 分,成績表現相對穩定,分數在 71 至 79 分之間波動,整體呈現���幅上升趨勢。弱點範圍集中,屬於可透過系統性複習改善的類型。",
|
| 233 |
+
weaknesses: "比較級與最高級的句型結構在六次測驗中有三次出現混淆,特別是不規則變化的形容詞(如 good/better/best、bad/worse/worst)容易與規則變化的形式搞混。部分題目在需要判斷兩者以上比較對象時,最高級句型的使用也不夠熟練。",
|
| 234 |
+
study_recommendations: "建議整理常見不規則比較級與最高級形容詞對照表,透過反覆的句型代換練習加強熟練度,直到能不假思索地正確變化。可加入情境式練習題,訓練根據比較對象數量(兩者 vs. 三者以上)正確選用比較級或最高級的判斷能力。",
|
| 235 |
+
},
|
| 236 |
+
"蕭恩翔": {
|
| 237 |
+
scores: [61, 59, 64, 63, 68, 66],
|
| 238 |
+
summary: "過去六次測驗平均 63.5 分,整體呈現緩步上升但仍不穩定的趨勢,分數在 59 至 68 分之間波動。文法結構相關的弱點是目前限制分數提升的主要因素。",
|
| 239 |
+
weaknesses: "被動語態句型的辨識度不足,六次測驗中有四次在主動與被動轉換的判斷上出現錯誤,尤其是含有情態助動詞的被動句型(如 should be done)容易誤判。一般助動詞的語意用法也不穩定,常混淆 must、should、may 等字在不同情境下的語氣強弱差異。",
|
| 240 |
+
study_recommendations: "建議加強主動轉被動的句型轉換練習,從簡單句開始建立轉換公式的直覺,再逐步加入含有助動詞的複雜句型。可搭配情境式練習複習常見助動詞的語意與用法差異,透過情境對比(義務、推測、允許等)釐清各助動詞的細微語氣區別。",
|
| 241 |
+
},
|
| 242 |
+
"陳筱琳": {
|
| 243 |
+
scores: [82, 85, 84, 87, 89, 88],
|
| 244 |
+
summary: "過去六次測驗平均 85.8 分,表現優異且持續進步,成績從 82 分提升至接近 90 分的水準,是全班進步幅度最穩定的學生之一。弱點範圍小且集中,具備衝刺高分的潛力。",
|
| 245 |
+
weaknesses: "特殊疑問句(Who/What/Where 等問答句)的語序在六次測驗中有兩次出現錯誤,問題主要發生在間接問句的轉換,容易保留直接問句的語序而未正確調整為子句語序。此弱點雖出現頻率不高,但在間接問句題型中辨識度明顯較低。",
|
| 246 |
+
study_recommendations: "建議針對疑問詞問答句進行語序重組練習,特別加強「疑問詞 + 直述句語序」這個間接問句的核心規則。可透過大量的直接問句轉間接問句練習,建立語序轉換的自動化反應,減少在複雜句型中出現語序錯誤的機率。",
|
| 247 |
+
},
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
function buildMockTimeline(name: string): TimelineEntry[] {
|
| 251 |
+
const p = MOCK_PROFILES[name];
|
| 252 |
+
return MOCK_QUIZ_TITLES_DAYS_AGO.map(([title, daysAgo], i) => {
|
| 253 |
+
const isLast = i === MOCK_QUIZ_TITLES_DAYS_AGO.length - 1;
|
| 254 |
+
const d = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
| 255 |
+
const dateStr = d.toISOString().split("T")[0];
|
| 256 |
+
return {
|
| 257 |
+
quiz_id: -(i + 1),
|
| 258 |
+
quiz_title: title,
|
| 259 |
+
quiz_created_at: `${dateStr}T09:00:00Z`,
|
| 260 |
+
quiz_date: dateStr,
|
| 261 |
+
score: p.scores[i],
|
| 262 |
+
weaknesses: isLast ? p.weaknesses : null,
|
| 263 |
+
study_recommendations: isLast ? p.study_recommendations : null,
|
| 264 |
+
report_id: null,
|
| 265 |
+
};
|
| 266 |
+
});
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// Overlays hand-written mock data as a *baseline* for a fixed set of named
|
| 270 |
+
// students — real uploads (once saved) or completed practice quizzes get
|
| 271 |
+
// merged in on top and take over as soon as they exist, so this is meant to
|
| 272 |
+
// be temporary scaffolding, not a permanent replacement for real data.
|
| 273 |
+
// TODO(demo): remove once these students have enough real quiz history.
|
| 274 |
+
function applyMockOverlay(real: StudentDetail, name: string): StudentDetail {
|
| 275 |
+
const p = MOCK_PROFILES[name];
|
| 276 |
+
if (!p) return real;
|
| 277 |
+
|
| 278 |
+
const mockTimeline = buildMockTimeline(name);
|
| 279 |
+
// Real quizzes are expected to postdate the mock ones (mock dates are all
|
| 280 |
+
// in the past, capped 7 days ago) — sort by date to stay correct even if
|
| 281 |
+
// a real quiz_date is backdated earlier than that.
|
| 282 |
+
const timeline = [...mockTimeline, ...real.timeline].sort(
|
| 283 |
+
(a, b) => (a.quiz_date ?? a.quiz_created_at).localeCompare(b.quiz_date ?? b.quiz_created_at)
|
| 284 |
+
);
|
| 285 |
+
|
| 286 |
+
// Most recent non-null weaknesses/recommendations across the merged
|
| 287 |
+
// timeline — mirrors the same "most recent wins" logic the backend uses
|
| 288 |
+
// in api_get_student, so a real graded quiz's real insights naturally
|
| 289 |
+
// take priority over the mock baseline once one exists.
|
| 290 |
+
let weaknesses: string | null = null;
|
| 291 |
+
let study_recommendations: string | null = null;
|
| 292 |
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
| 293 |
+
if (weaknesses === null && timeline[i].weaknesses) weaknesses = timeline[i].weaknesses;
|
| 294 |
+
if (study_recommendations === null && timeline[i].study_recommendations) study_recommendations = timeline[i].study_recommendations;
|
| 295 |
+
if (weaknesses && study_recommendations) break;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return {
|
| 299 |
+
student: real.student,
|
| 300 |
+
timeline,
|
| 301 |
+
weaknesses,
|
| 302 |
+
study_recommendations,
|
| 303 |
+
// Cross-quiz synthesis: prefer the real cached version once a real
|
| 304 |
+
// report has actually been generated; fall back to the mock summary.
|
| 305 |
+
ai_summary: real.ai_summary || p.summary,
|
| 306 |
+
ai_focus_areas: real.ai_focus_areas || p.weaknesses,
|
| 307 |
+
ai_study_recommendations: real.ai_study_recommendations || p.study_recommendations,
|
| 308 |
+
};
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
// ── Constants ─────────────────────────────────────────────────────────────────
|
| 312 |
|
| 313 |
const AVATAR_COLORS = [
|
|
|
|
| 376 |
);
|
| 377 |
}
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
export function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
| 380 |
return (
|
| 381 |
<button
|
|
|
|
| 1104 |
|
| 1105 |
// ── Main component ────────────────────────────────────────────────────────────
|
| 1106 |
|
| 1107 |
+
// Demo mock data (see MOCK_PROFILES below) only ever shows on this one
|
| 1108 |
+
// account — every other teacher, even one with a same-named real student,
|
| 1109 |
+
// always sees only their own real data.
|
| 1110 |
+
const MOCK_DATA_ACCOUNT_EMAIL = "melfurever21@gmail.com";
|
| 1111 |
+
|
| 1112 |
+
export function TeacherDashboard({ active = true, userEmail = "" }: { active?: boolean; userEmail?: string }) {
|
| 1113 |
+
const mockDataEnabled = userEmail === MOCK_DATA_ACCOUNT_EMAIL;
|
| 1114 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 1115 |
const [search, setSearch] = useState("");
|
| 1116 |
const [selected, setSelected] = useState<StudentListItem | null>(null);
|
| 1117 |
const [assessmentView, setAssessmentView] = useState<"graph" | "table">("graph");
|
|
|
|
|
|
|
|
|
|
| 1118 |
const [autoSelectWeak, setAutoSelectWeak] = useState(true);
|
| 1119 |
|
| 1120 |
const [students, setStudents] = useState<StudentListItem[]>([]);
|
|
|
|
| 1151 |
// (and wait) on every student selection. Still re-fetched here (even
|
| 1152 |
// when cached) to pick up anything saved since the last visit.
|
| 1153 |
const d = await apiGet<StudentDetail>(`/api/students/${student.id}`);
|
| 1154 |
+
// Demo overlay — see TODO(demo) above. Only ever active on the one
|
| 1155 |
+
// demo account (mockDataEnabled); every other teacher always sees
|
| 1156 |
+
// only their own real data, even for a same-named student. Real
|
| 1157 |
+
// quiz history (an upload or a completed practice quiz) merges in
|
| 1158 |
+
// and takes over automatically.
|
| 1159 |
+
const merged = mockDataEnabled && MOCK_PROFILES[student.name] ? applyMockOverlay(d, student.name) : d;
|
| 1160 |
+
setDetail(merged);
|
| 1161 |
+
setDetailCache((prev) => ({ ...prev, [student.id]: merged }));
|
| 1162 |
} catch {
|
| 1163 |
+
if (!cached) {
|
| 1164 |
+
// Even if the real fetch fails, still show the mock baseline for
|
| 1165 |
+
// these named students rather than a blank dashboard.
|
| 1166 |
+
if (mockDataEnabled && MOCK_PROFILES[student.name]) {
|
| 1167 |
+
setDetail(applyMockOverlay(
|
| 1168 |
+
{ student: { id: student.id, name: student.name, email: "" }, timeline: [], weaknesses: null, study_recommendations: null, ai_summary: "", ai_focus_areas: "", ai_study_recommendations: "" },
|
| 1169 |
+
student.name,
|
| 1170 |
+
));
|
| 1171 |
+
} else {
|
| 1172 |
+
setDetail(null);
|
| 1173 |
+
}
|
| 1174 |
+
}
|
| 1175 |
} finally {
|
| 1176 |
setDetailLoading(false);
|
| 1177 |
}
|
|
|
|
| 1222 |
|
| 1223 |
const handleSelectStudent = (student: StudentListItem) => {
|
| 1224 |
setSelected(student);
|
|
|
|
| 1225 |
refreshDetail(student);
|
| 1226 |
};
|
| 1227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1228 |
// Derived metrics from real timeline data
|
| 1229 |
const timeline = detail?.timeline ?? [];
|
| 1230 |
const scores = timeline.map((t) => Math.round(t.score));
|
|
|
|
| 1425 |
</div>
|
| 1426 |
</div>
|
| 1427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1428 |
|
| 1429 |
{/* ── Performance History ────────────────────────────────────── */}
|
| 1430 |
<div className="card p-6">
|
chatkit/frontend/src/components/step2/AnswerGridEditor.tsx
CHANGED
|
@@ -339,9 +339,14 @@ export function AnswerGridEditor({
|
|
| 339 |
<button
|
| 340 |
onClick={handleSave}
|
| 341 |
disabled={saving}
|
| 342 |
-
className="btn btn-primary text-sm disabled:opacity-50"
|
| 343 |
>
|
| 344 |
-
{saving ?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
</button>
|
| 346 |
</div>
|
| 347 |
</div>
|
|
|
|
| 339 |
<button
|
| 340 |
onClick={handleSave}
|
| 341 |
disabled={saving}
|
| 342 |
+
className="btn btn-primary text-sm min-w-36 disabled:opacity-50"
|
| 343 |
>
|
| 344 |
+
{saving ? (
|
| 345 |
+
<span className="inline-flex items-center gap-1.5">
|
| 346 |
+
<span className="w-3 h-3 border border-white/50 border-t-white rounded-full animate-spin shrink-0" />
|
| 347 |
+
儲存中...
|
| 348 |
+
</span>
|
| 349 |
+
) : saved ? "立即重新儲存" : "立即儲存"}
|
| 350 |
</button>
|
| 351 |
</div>
|
| 352 |
</div>
|