taboola commited on
Commit
1acff4f
·
1 Parent(s): 2572915

restore demo data, remove category breakdown

Browse files
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 app
49
- # applies pending migrations on startup (see main.py's lifespan), since
50
- # there's no 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,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 ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
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
- def run_pending_migrations() -> None:
130
- """Apply any pending Alembic migrations against Postgres/Supabase.
131
-
132
- There's no separate deploy step or CI job that runs `alembic upgrade
133
- head` against production, and whoever deploys this may not have direct
134
- database access to run it by hand so instead this runs once at app
135
- startup (see main.py's lifespan). `alembic upgrade head` is idempotent
136
- (a no-op once already current), so this is safe to run on every boot.
137
-
138
- Synchronous (alembic's `command.upgrade` drives env.py's own internal
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, run_pending_migrations
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
- from .config import get_settings
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
- export function TeacherDashboard({ active = true }: { active?: boolean }) {
 
 
 
 
 
 
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
- setDetail(d);
1171
- setDetailCache((prev) => ({ ...prev, [student.id]: d }));
 
 
 
 
 
 
1172
  } catch {
1173
- if (!cached) setDetail(null);
 
 
 
 
 
 
 
 
 
 
 
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 ? "儲存中..." : saved ? "立即重新儲存" : "立即儲存"}
 
 
 
 
 
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>