Skip to content

Commit 20672f3

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 a6c88d3 commit 20672f3

8 files changed

Lines changed: 309 additions & 45 deletions

File tree

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

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ function statusColor(status: string): string {
7070
// ── Component ────────────────────────────────────────────────────────────────
7171

7272
export default function CourseDashboard() {
73-
const { props, isPending } = useWidget<Props>();
73+
const { props, isPending, sendFollowUpMessage } = useWidget<Props>();
7474
const theme = useWidgetTheme();
7575
const [activeFilter, setActiveFilter] = useState<string>("all");
7676

@@ -99,6 +99,11 @@ export default function CourseDashboard() {
9999
const labelForFilter =
100100
activeFilter === "all" ? "All" : activeFilter.charAt(0).toUpperCase() + activeFilter.slice(1);
101101

102+
// With nothing to filter, the chip row collapses to a lone "All" that does
103+
// nothing — a control the empty-state copy would otherwise be pointing at.
104+
const hasFilters = props.courses.length > 0 && allStatuses.length > 1;
105+
const firstRun = props.total === 0 && props.status === "all";
106+
102107
return (
103108
<McpUseProvider autoSize>
104109
<Brand />
@@ -108,15 +113,17 @@ export default function CourseDashboard() {
108113
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
109114
<div>
110115
<h2 className="m-0 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
111-
{labelForFilter} Courses
116+
{firstRun ? "Courses" : `${labelForFilter} Courses`}
112117
</h2>
113-
<p className="mt-0.5 mb-0 text-[13px] text-zinc-500 dark:text-zinc-400">
114-
{props.total} course{props.total !== 1 ? "s" : ""} total
115-
</p>
118+
{!firstRun && (
119+
<p className="mt-0.5 mb-0 text-[13px] text-zinc-500 dark:text-zinc-400">
120+
{props.total} course{props.total !== 1 ? "s" : ""} total
121+
</p>
122+
)}
116123
</div>
117124

118125
{/* Status filter tabs */}
119-
<div className="flex flex-wrap gap-1.5">
126+
<div className={`flex flex-wrap gap-1.5 ${hasFilters ? "" : "hidden"}`}>
120127
{allStatuses.map((s) => {
121128
const active = s === activeFilter;
122129
return (
@@ -136,13 +143,65 @@ export default function CourseDashboard() {
136143
</div>
137144
</div>
138145

139-
{/* Empty state */}
140-
{filtered.length === 0 && (
141-
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
142-
<div className="mb-3 text-4xl">📚</div>
143-
<p className="m-0 text-sm">No courses match this filter</p>
144-
</div>
145-
)}
146+
{/* Empty states — three different situations, three different answers. */}
147+
{filtered.length === 0 &&
148+
(props.total === 0 ? (
149+
props.status === "all" ? (
150+
/* First run: no courses exist at all, so there is no filter to
151+
blame and the teacher needs a way forward, not a dead end. */
152+
<div className="p-12 text-center">
153+
<div className="mb-3 text-4xl">📚</div>
154+
<p className="m-0 text-[15px] font-semibold text-zinc-900 dark:text-zinc-100">
155+
No courses yet
156+
</p>
157+
<p className="mx-auto mt-1.5 mb-0 max-w-sm text-[13px] text-zinc-500 dark:text-zinc-400">
158+
Your first course is where lessons, exams and enrollments
159+
live. Start with a title and an outline — you can publish it
160+
later.
161+
</p>
162+
<button
163+
onClick={() =>
164+
sendFollowUpMessage(
165+
"I'd like to create my first course. Ask me for a title and what it should cover, then create it."
166+
)
167+
}
168+
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"
169+
>
170+
Create my first course
171+
</button>
172+
</div>
173+
) : (
174+
/* The tool itself was called with a status filter. */
175+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
176+
<div className="mb-3 text-4xl">📚</div>
177+
<p className="m-0 text-sm">
178+
No {props.status} courses yet
179+
</p>
180+
</div>
181+
)
182+
) : activeFilter !== "all" ? (
183+
/* Courses exist; the chip above filtered them all out. */
184+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
185+
<div className="mb-3 text-4xl">📚</div>
186+
<p className="m-0 text-sm">No courses match this filter</p>
187+
<button
188+
onClick={() => setActiveFilter("all")}
189+
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"
190+
>
191+
Show all {props.total} course{props.total !== 1 ? "s" : ""}
192+
</button>
193+
</div>
194+
) : (
195+
/* Courses exist and no chip is active, so this page simply has no
196+
rows — an offset past the end of the list. Blaming a filter here
197+
would point at a control the teacher never touched. */
198+
<div className="p-12 text-center text-zinc-400 dark:text-zinc-500">
199+
<div className="mb-3 text-4xl">📚</div>
200+
<p className="m-0 text-sm">
201+
No courses on this page — {props.total} in total
202+
</p>
203+
</div>
204+
))}
146205

147206
{/* Card grid */}
148207
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import {
77
type WidgetMetadata,
88
} from "mcp-use/react";
99
import { Brand } from "../shared/branding";
10+
import { isNamedStudent, studentDisplayName } from "../shared/student-display";
1011
import { z } from "zod";
1112
import { Markdown } from "../shared/markdown";
1213

1314
// ── Schema ──────────────────────────────────────────────────────────────────
1415

1516
const submissionSchema = z.object({
1617
id: z.number(),
17-
student_name: z.string(),
18+
// `lms_list_exam_submissions` sends `null` when the profile has no full name.
19+
student_name: z.string().nullable(),
1820
score: z.number().nullable(),
1921
submission_date: z.string(),
2022
review_status: z.string().nullable(),
@@ -206,8 +208,14 @@ export default function ExamSubmissions() {
206208
: "bg-transparent hover:bg-zinc-50 dark:hover:bg-zinc-800"
207209
}`}
208210
>
209-
<span className="overflow-hidden text-[13px] font-medium text-ellipsis whitespace-nowrap text-zinc-900 dark:text-zinc-100">
210-
{sub.student_name}
211+
<span
212+
className={`overflow-hidden text-[13px] font-medium text-ellipsis whitespace-nowrap ${
213+
isNamedStudent(sub.student_name)
214+
? "text-zinc-900 dark:text-zinc-100"
215+
: "text-zinc-400 italic dark:text-zinc-500"
216+
}`}
217+
>
218+
{studentDisplayName(sub.student_name)}
211219
</span>
212220
<span className="text-[13px] font-semibold text-zinc-900 dark:text-zinc-100">
213221
{sub.score != null ? `${sub.score}%` : "—"}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
/** Placeholder shown in place of a name we do not have. */
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+
/** The name to render — never a user id. */
28+
export function studentDisplayName(name: string | null | undefined): string {
29+
return isNamedStudent(name) ? name!.trim() : UNNAMED_STUDENT;
30+
}
31+
32+
/**
33+
* Avatar initials. Degrades to a neutral glyph rather than letters derived from
34+
* an id, which would read as someone's initials without being anyone's.
35+
*/
36+
export function studentInitials(name: string | null | undefined): string {
37+
if (!isNamedStudent(name)) return "·";
38+
const parts = name!.trim().split(/\s+/);
39+
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")) || "·";
40+
}

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
type WidgetMetadata,
77
} from "mcp-use/react";
88
import { Brand } from "../shared/branding";
9+
import {
10+
isNamedStudent,
11+
studentDisplayName,
12+
studentInitials,
13+
} from "../shared/student-display";
914
import { z } from "zod";
1015

1116
// ── Schema ──────────────────────────────────────────────────────────────────
@@ -74,12 +79,24 @@ function progressColor(pct: number | null): string {
7479
return "bg-red-600 dark:bg-red-400";
7580
}
7681

77-
function initials(name: string | null, id: string): string {
78-
if (name) {
79-
const parts = name.trim().split(/\s+/);
80-
return (parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "");
82+
/**
83+
* The exam column reads as three distinct states. `—` is this widget's glyph
84+
* for "no data" everywhere else, so it may only mean "has not sat an exam" —
85+
* a submission that is waiting on a grade has to say so, or it reads as a
86+
* student who took an exam and scored nothing.
87+
*/
88+
function examCell(avg: number | null, count: number) {
89+
if (count > 0 && avg == null) {
90+
return {
91+
value: "Ungraded",
92+
valueClass:
93+
"text-[12.5px] font-semibold text-amber-600 dark:text-amber-400",
94+
};
8195
}
82-
return id.slice(0, 2).toUpperCase();
96+
return {
97+
value: avg == null ? "—" : `${avg}%`,
98+
valueClass: "text-sm font-bold text-zinc-900 dark:text-zinc-100",
99+
};
83100
}
84101

85102
// ── Component ────────────────────────────────────────────────────────────────
@@ -171,7 +188,9 @@ export default function StudentProgressRoster() {
171188
<div className="flex flex-col gap-2">
172189
{visible.map((s: Student) => {
173190
const pct = s.progress_pct;
174-
const name = s.student_name ?? s.student_id.slice(0, 8);
191+
const named = isNamedStudent(s.student_name);
192+
const name = studentDisplayName(s.student_name);
193+
const exam = examCell(s.exam_avg, s.exam_count);
175194
return (
176195
<div
177196
key={s.student_id}
@@ -183,13 +202,19 @@ export default function StudentProgressRoster() {
183202
>
184203
{/* Avatar */}
185204
<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)]">
186-
{initials(s.student_name, s.student_id)}
205+
{studentInitials(s.student_name)}
187206
</div>
188207

189208
{/* Name + progress bar */}
190209
<div className="min-w-40 flex-1">
191210
<div className="mb-1.5 flex items-center gap-2">
192-
<span className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
211+
<span
212+
className={`truncate text-sm ${
213+
named
214+
? "font-semibold text-zinc-900 dark:text-zinc-100"
215+
: "font-medium text-zinc-400 italic dark:text-zinc-500"
216+
}`}
217+
>
193218
{name}
194219
</span>
195220
{s.at_risk && (
@@ -221,10 +246,8 @@ export default function StudentProgressRoster() {
221246
</div>
222247
</div>
223248

224-
<div className="min-w-16 shrink-0 text-right">
225-
<div className="text-sm font-bold text-zinc-900 dark:text-zinc-100">
226-
{s.exam_avg == null ? "—" : `${s.exam_avg}%`}
227-
</div>
249+
<div className="min-w-20 shrink-0 text-right">
250+
<div className={exam.valueClass}>{exam.value}</div>
228251
<div className="text-[11px] text-zinc-400 dark:text-zinc-500">
229252
{s.exam_count} exam{s.exam_count === 1 ? "" : "s"}
230253
</div>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type WidgetMetadata,
88
} from "mcp-use/react";
99
import { Brand } from "../shared/branding";
10+
import { studentDisplayName } from "../shared/student-display";
1011
import { z } from "zod";
1112
import { Markdown } from "../shared/markdown";
1213

@@ -163,7 +164,7 @@ export default function SubmissionGrader() {
163164
});
164165
};
165166

166-
const studentName = submission.student_name ?? submission.student_id.slice(0, 8);
167+
const studentName = studentDisplayName(submission.student_name);
167168
const saveDisabled = saving || (!dirty && status === "teacher_reviewed");
168169

169170
return (

0 commit comments

Comments
 (0)