Skip to content

Commit c06b4e2

Browse files
fix(widgets): stop rendering missing data as if it were real (#568)
Three widget states presented absent data as a value a teacher could act on, plus three adjacent defects found while fixing them. A student with no `profiles.full_name` rendered as a slice of their user id — `u-008` in the name column, `U-` in the avatar. That is not a name, identifies nobody, and reads as corrupted data. `student_name: null` is a normal value (fetchProfileNames drops blanks), so the fallback now goes through a shared `resources/shared/student-display.ts`: "Unnamed student" in a muted italic that reads as a placeholder, and a neutral avatar glyph instead of letters derived from an id. `submission-grader` had the same `student_id.slice(0, 8)` fallback; `exam-submissions` declared `student_name` non-nullable while its tool sends null, so a nameless student rendered as an empty cell. The roster showed "1 exam" beside an em dash — the same glyph it uses for "no data" elsewhere — so an ungraded submission read as an exam scored nothing. It now reads "Ungraded". That state was also unreachable from real data: `exam_count` was incremented only `if (score != null)`, which made an ungraded submission vanish entirely and the student read as "0 exams". The aggregation is extracted as `aggregateExamSubmissions()` and now counts every submission while averaging only the graded ones. The course dashboard blamed a filter for every empty result, including the first-run case where no filter exists, the chips have collapsed to a lone "All", and the teacher is offered no way forward. It now branches: no courses at all (first-run copy plus a create-course action, chips hidden), no courses of a server-filtered status, a chip that matched nothing, and a page past the end of the list. That last case is newly reachable because `lms_list_courses` was reporting a hardcoded `total: 0` on an empty page, discarding the real count — which would have shown a teacher with courses the "create your first course" screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VSGmz5frZqzbpQXJxxuqTs
1 parent 8bb844b commit c06b4e2

8 files changed

Lines changed: 348 additions & 44 deletions

File tree

mcp-server/resources/course-dashboard/widget.tsx

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ const STRINGS = {
8484
archived: "Archived",
8585
} as Record<string, string>,
8686
emptyFiltered: "No courses match this filter",
87+
// A teacher with no courses at all is not looking at a filtered result —
88+
// there is no filter to blame and they need a way forward, not a dead end.
89+
headingFirstRun: "Courses",
90+
emptyFirstRunTitle: "No courses yet",
91+
emptyFirstRunBody:
92+
"Your first course is where lessons, exams and enrollments live. Start with a title and an outline — you can publish it later.",
93+
emptyFirstRunCta: "Create my first course",
94+
// Sent to the model when that button is pressed.
95+
emptyFirstRunPrompt:
96+
"I'd like to create my first course. Ask me for a title and what it should cover, then create it.",
97+
emptyStatus: (label: string) => `No ${label.toLowerCase()} courses yet`,
98+
emptyPage: (count: string) => `No courses on this page — ${count} in total`,
99+
showAll: (n: number, count: string) => `Show all ${count} course${n === 1 ? "" : "s"}`,
87100
lessons: (n: number, count: string) => `${count} lesson${n === 1 ? "" : "s"}`,
88101
enrolled: (count: string) => `${count} enrolled`,
89102
},
@@ -125,6 +138,17 @@ const STRINGS = {
125138
archived: "Archivado",
126139
} as Record<string, string>,
127140
emptyFiltered: "Ningún curso coincide con este filtro",
141+
headingFirstRun: "Cursos",
142+
emptyFirstRunTitle: "Todavía no tienes cursos",
143+
emptyFirstRunBody:
144+
"Tu primer curso es donde viven las lecciones, los exámenes y las inscripciones. Empieza con un título y un esquema — puedes publicarlo más tarde.",
145+
emptyFirstRunCta: "Crear mi primer curso",
146+
emptyFirstRunPrompt:
147+
"Quiero crear mi primer curso. Pregúntame por el título y de qué debería tratar, y luego créalo.",
148+
emptyStatus: (label: string) => `Todavía no hay cursos con el estado «${label.toLowerCase()}»`,
149+
emptyPage: (count: string) => `No hay cursos en esta página — ${count} en total`,
150+
showAll: (n: number, count: string) =>
151+
`Ver ${n === 1 ? "el" : "los"} ${count} curso${n === 1 ? "" : "s"}`,
128152
lessons: (n: number, count: string) =>
129153
`${count} ${n === 1 ? "lección" : "lecciones"}`,
130154
enrolled: (count: string) => `${count} inscritos`,
@@ -161,7 +185,7 @@ function statusColor(status: string): string {
161185
// ── Component ────────────────────────────────────────────────────────────────
162186

163187
export default function CourseDashboard() {
164-
const { props, isPending } = useWidget<Props>();
188+
const { props, isPending, sendFollowUpMessage } = useWidget<Props>();
165189
const theme = useWidgetTheme();
166190
const [activeFilter, setActiveFilter] = useState<string>("all");
167191

@@ -189,6 +213,11 @@ export default function CourseDashboard() {
189213
? props.courses
190214
: props.courses.filter((c) => c.status === activeFilter);
191215

216+
// With nothing to filter, the chip row collapses to a lone "All" that does
217+
// nothing — a control the empty-state copy would otherwise be pointing at.
218+
const hasFilters = props.courses.length > 0 && allStatuses.length > 1;
219+
const firstRun = props.total === 0 && props.status === "all";
220+
192221
return (
193222
<McpUseProvider autoSize>
194223
<Brand />
@@ -198,15 +227,17 @@ export default function CourseDashboard() {
198227
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
199228
<div>
200229
<h2 className="m-0 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
201-
{t.heading(activeFilter)}
230+
{firstRun ? t.headingFirstRun : t.heading(activeFilter)}
202231
</h2>
203-
<p className="mt-0.5 mb-0 text-[13px] text-zinc-500 dark:text-zinc-400">
204-
{t.totalCount(props.total, fmt.number(props.total))}
205-
</p>
232+
{!firstRun && (
233+
<p className="mt-0.5 mb-0 text-[13px] text-zinc-500 dark:text-zinc-400">
234+
{t.totalCount(props.total, fmt.number(props.total))}
235+
</p>
236+
)}
206237
</div>
207238

208239
{/* Status filter tabs */}
209-
<div className="flex flex-wrap gap-1.5">
240+
<div className={`flex flex-wrap gap-1.5 ${hasFilters ? "" : "hidden"}`}>
210241
{allStatuses.map((s) => {
211242
const active = s === activeFilter;
212243
return (
@@ -227,12 +258,55 @@ export default function CourseDashboard() {
227258
</div>
228259

229260
{/* Empty state */}
230-
{filtered.length === 0 && (
231-
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
232-
<div className="mb-3 text-4xl">📚</div>
233-
<p className="m-0 text-sm">{t.emptyFiltered}</p>
234-
</div>
235-
)}
261+
{/* Empty states — four different situations, four different answers. */}
262+
{filtered.length === 0 &&
263+
(props.total === 0 ? (
264+
firstRun ? (
265+
/* No courses exist at all: no filter to blame, and a first-run
266+
teacher needs a way forward rather than a dead end. */
267+
<div className="p-12 text-center">
268+
<div className="mb-3 text-4xl">📚</div>
269+
<p className="m-0 text-[15px] font-semibold text-zinc-900 dark:text-zinc-100">
270+
{t.emptyFirstRunTitle}
271+
</p>
272+
<p className="mx-auto mt-1.5 mb-0 max-w-sm text-[13px] text-zinc-500 dark:text-zinc-400">
273+
{t.emptyFirstRunBody}
274+
</p>
275+
<button
276+
onClick={() => sendFollowUpMessage(t.emptyFirstRunPrompt)}
277+
className="mt-4 cursor-pointer rounded-lg border border-[var(--brand-600)] bg-[var(--brand-600)] px-4 py-2 text-[13px] font-semibold text-white dark:border-[var(--brand-400)] dark:bg-[var(--brand-400)] dark:text-zinc-950"
278+
>
279+
{t.emptyFirstRunCta}
280+
</button>
281+
</div>
282+
) : (
283+
/* The tool itself was called with a status filter. */
284+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
285+
<div className="mb-3 text-4xl">📚</div>
286+
<p className="m-0 text-sm">{t.emptyStatus(t.filterLabel(props.status))}</p>
287+
</div>
288+
)
289+
) : activeFilter !== "all" ? (
290+
/* Courses exist; the chip above filtered them all out. */
291+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
292+
<div className="mb-3 text-4xl">📚</div>
293+
<p className="m-0 text-sm">{t.emptyFiltered}</p>
294+
<button
295+
onClick={() => setActiveFilter("all")}
296+
className="mt-3 cursor-pointer rounded-lg border border-zinc-200 bg-transparent px-3.5 py-1.5 text-[12.5px] font-medium text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"
297+
>
298+
{t.showAll(props.total, fmt.number(props.total))}
299+
</button>
300+
</div>
301+
) : (
302+
/* Courses exist and no chip is active, so this page simply has no
303+
rows — an offset past the end of the list. Blaming a filter here
304+
would point at a control the teacher never touched. */
305+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
306+
<div className="mb-3 text-4xl">📚</div>
307+
<p className="m-0 text-sm">{t.emptyPage(fmt.number(props.total))}</p>
308+
</div>
309+
))}
236310

237311
{/*
238312
Card grid, three shared rows: heading, body (description + tags),

mcp-server/resources/exam-submissions/widget.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import {
88
} from "mcp-use/react";
99
import { Brand } from "../shared/branding";
1010
import { useFormat, useStrings } from "../shared/i18n";
11+
import { isNamedStudent, studentDisplayName } from "../shared/student-display";
1112
import { z } from "zod";
1213
import { Markdown } from "../shared/markdown";
1314

1415
// ── Schema ──────────────────────────────────────────────────────────────────
1516

1617
const submissionSchema = z.object({
1718
id: z.number(),
18-
student_name: z.string(),
19+
// `lms_list_exam_submissions` sends `null` when the profile has no full name.
20+
student_name: z.string().nullable(),
1921
score: z.number().nullable(),
2022
submission_date: z.string(),
2123
review_status: z.string().nullable(),
@@ -52,6 +54,7 @@ const STRINGS = {
5254
colDate: "Date",
5355
colStatus: "Status",
5456
empty: "No submissions yet",
57+
unnamedStudent: "Unnamed student",
5558
loadingDetails: "Loading details…",
5659
detailScore: "Score",
5760
detailReviewStatus: "Review status",
@@ -75,6 +78,7 @@ const STRINGS = {
7578
colDate: "Fecha",
7679
colStatus: "Estado",
7780
empty: "Todavía no hay entregas",
81+
unnamedStudent: "Estudiante sin nombre",
7882
loadingDetails: "Cargando detalles…",
7983
detailScore: "Nota",
8084
detailReviewStatus: "Estado de revisión",
@@ -253,8 +257,14 @@ export default function ExamSubmissions() {
253257
: "bg-transparent hover:bg-zinc-50 dark:hover:bg-zinc-800"
254258
}`}
255259
>
256-
<span className="overflow-hidden text-[13px] font-medium text-ellipsis whitespace-nowrap text-zinc-900 dark:text-zinc-100">
257-
{sub.student_name}
260+
<span
261+
className={`overflow-hidden text-[13px] font-medium text-ellipsis whitespace-nowrap ${
262+
isNamedStudent(sub.student_name)
263+
? "text-zinc-900 dark:text-zinc-100"
264+
: "text-zinc-400 italic dark:text-zinc-500"
265+
}`}
266+
>
267+
{studentDisplayName(sub.student_name, t.unnamedStudent)}
258268
</span>
259269
<span className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">
260270
{fmt.percent(sub.score)}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* How widgets render a student whose profile has no display name.
3+
*
4+
* `profiles.full_name` is nullable and `fetchProfileNames()` deliberately drops
5+
* blanks, so `student_name: null` is a normal value on every roster — not an
6+
* error case. Each widget used to invent its own fallback out of the user id
7+
* (`student_id.slice(0, 8)`, initials from the same slice), which put strings
8+
* like `u-008` and `U-` in the name column: a teacher reads that as corrupted
9+
* data, and it identifies nobody. The id is not a name and must never stand in
10+
* for one.
11+
*
12+
* Resolving the real person would mean their email, which lives in
13+
* `auth.users` and is reachable only through `auth.admin.getUserById()` — a
14+
* service-role call these tools cannot make on the caller's RLS-scoped client.
15+
* So the honest answer is a generic placeholder, rendered visibly as a
16+
* placeholder (see `isNamedStudent`) rather than passed off as a name.
17+
*/
18+
19+
/** English placeholder; callers with a `STRINGS` table pass their own. */
20+
export const UNNAMED_STUDENT = "Unnamed student";
21+
22+
/** Whether the roster actually knows this student's name. */
23+
export function isNamedStudent(name: string | null | undefined): boolean {
24+
return !!name && name.trim().length > 0;
25+
}
26+
27+
/**
28+
* The name to render — never a user id.
29+
*
30+
* The placeholder is a widget-owned string, so it is translated like the rest
31+
* of them (`resources/shared/i18n.tsx`) and passed in; a real `full_name` is
32+
* database content and is always rendered verbatim.
33+
*/
34+
export function studentDisplayName(
35+
name: string | null | undefined,
36+
unnamedLabel: string = UNNAMED_STUDENT
37+
): string {
38+
return isNamedStudent(name) ? name!.trim() : unnamedLabel;
39+
}
40+
41+
/**
42+
* Avatar initials. Degrades to a neutral glyph rather than letters derived from
43+
* an id, which would read as someone's initials without being anyone's.
44+
*/
45+
export function studentInitials(name: string | null | undefined): string {
46+
if (!isNamedStudent(name)) return "·";
47+
const parts = name!.trim().split(/\s+/);
48+
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")) || "·";
49+
}

mcp-server/resources/student-progress-roster/widget.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Brand } from "../shared/branding";
99
import { useFormat, useStrings } from "../shared/i18n";
1010
import { NEUTRAL_TEXT, barClass, textClass } from "../shared/severity";
11+
import { isNamedStudent, studentDisplayName, studentInitials } from "../shared/student-display";
1112
import { z } from "zod";
1213

1314
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -71,6 +72,11 @@ const STRINGS = {
7172
lastActive: "last active",
7273
noAtRisk: "No at-risk students. 🎉",
7374
noStudents: "No students enrolled.",
75+
// Shown when `profiles.full_name` is empty. Never the user id: that names
76+
// nobody and reads as corrupted data.
77+
unnamedStudent: "Unnamed student",
78+
// An exam that was submitted but not yet scored — distinct from "no exam".
79+
ungraded: "Ungraded",
7480
},
7581
es: {
7682
loading: "Cargando lista…",
@@ -87,17 +93,32 @@ const STRINGS = {
8793
lastActive: "última actividad",
8894
noAtRisk: "Ningún estudiante en riesgo. 🎉",
8995
noStudents: "No hay estudiantes inscritos.",
96+
unnamedStudent: "Estudiante sin nombre",
97+
ungraded: "Sin calificar",
9098
},
9199
};
92100

93101
// ── Helpers ──────────────────────────────────────────────────────────────────
94102

95-
function initials(name: string | null, id: string): string {
96-
if (name) {
97-
const parts = name.trim().split(/\s+/);
98-
return (parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "");
103+
/**
104+
* The exam column reads as three distinct states. `—` (what `fmt.percent`
105+
* renders for null) is this widget's glyph for "no data" everywhere else, so it
106+
* may only mean "has not sat an exam" — a submission waiting on a grade has to
107+
* say so, or it reads as a student who took an exam and scored nothing.
108+
*/
109+
function examCell(
110+
avg: number | null,
111+
count: number,
112+
ungradedLabel: string,
113+
fmtPercent: (n: number | null) => string
114+
): { value: string; valueClass: string } {
115+
if (count > 0 && avg == null) {
116+
return {
117+
value: ungradedLabel,
118+
valueClass: "text-[12.5px] font-semibold text-amber-600 dark:text-amber-400",
119+
};
99120
}
100-
return id.slice(0, 2).toUpperCase();
121+
return { value: fmtPercent(avg), valueClass: `text-sm font-bold ${textClass(avg)}` };
101122
}
102123

103124
// ── Component ────────────────────────────────────────────────────────────────
@@ -195,7 +216,9 @@ export default function StudentProgressRoster() {
195216
<div className="flex flex-col gap-2">
196217
{visible.map((s: Student) => {
197218
const pct = s.progress_pct;
198-
const name = s.student_name ?? s.student_id.slice(0, 8);
219+
const named = isNamedStudent(s.student_name);
220+
const name = studentDisplayName(s.student_name, t.unnamedStudent);
221+
const exam = examCell(s.exam_avg, s.exam_count, t.ungraded, fmt.percent);
199222
return (
200223
<div
201224
key={s.student_id}
@@ -207,13 +230,19 @@ export default function StudentProgressRoster() {
207230
>
208231
{/* Avatar */}
209232
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-[var(--brand-50)] text-[13px] font-bold text-[var(--brand-600)] uppercase dark:bg-[var(--brand-950)] dark:text-[var(--brand-400)]">
210-
{initials(s.student_name, s.student_id)}
233+
{studentInitials(s.student_name)}
211234
</div>
212235

213236
{/* Name + progress bar */}
214237
<div className="min-w-40 flex-1">
215238
<div className="mb-1.5 flex items-center gap-2">
216-
<span className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
239+
<span
240+
className={`truncate text-sm ${
241+
named
242+
? "font-semibold text-zinc-900 dark:text-zinc-100"
243+
: "font-medium text-zinc-400 italic dark:text-zinc-500"
244+
}`}
245+
>
217246
{name}
218247
</span>
219248
{s.at_risk && (
@@ -248,10 +277,8 @@ export default function StudentProgressRoster() {
248277
</div>
249278
</div>
250279

251-
<div className="min-w-16 shrink-0 text-right">
252-
<div className={`text-sm font-bold ${textClass(s.exam_avg)}`}>
253-
{fmt.percent(s.exam_avg)}
254-
</div>
280+
<div className="min-w-20 shrink-0 text-right">
281+
<div className={exam.valueClass}>{exam.value}</div>
255282
<div className="text-[11px] text-zinc-400 dark:text-zinc-500">
256283
{t.exams(s.exam_count, fmt.number(s.exam_count))}
257284
</div>

mcp-server/resources/submission-grader/widget.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "mcp-use/react";
99
import { Brand } from "../shared/branding";
1010
import { useFormat, useStrings } from "../shared/i18n";
11+
import { studentDisplayName } from "../shared/student-display";
1112
import { z } from "zod";
1213
import { Markdown } from "../shared/markdown";
1314

@@ -79,6 +80,7 @@ const STRINGS = {
7980
saving: "Saving…",
8081
saved: "Saved ✓",
8182
saveGrade: "Save grade",
83+
unnamedStudent: "Unnamed student",
8284
saveFailed: "Save failed",
8385
submitted: "submitted",
8486
studentAnswer: "Student answer",
@@ -106,6 +108,7 @@ const STRINGS = {
106108
saving: "Guardando…",
107109
saved: "Guardado ✓",
108110
saveGrade: "Guardar nota",
111+
unnamedStudent: "Estudiante sin nombre",
109112
saveFailed: "Error al guardar",
110113
submitted: "entregado el",
111114
studentAnswer: "Respuesta del estudiante",
@@ -210,7 +213,7 @@ export default function SubmissionGrader() {
210213
});
211214
};
212215

213-
const studentName = submission.student_name ?? submission.student_id.slice(0, 8);
216+
const studentName = studentDisplayName(submission.student_name, t.unnamedStudent);
214217
const saveDisabled = saving || (!dirty && status === "teacher_reviewed");
215218

216219
return (

0 commit comments

Comments
 (0)