fix(widgets): stop rendering missing data as if it were real (#568) - #575
Conversation
Screenshots and A/B GIFs backing PR #575. Kept on the branch rather than master so the PR carries its own evidence; safe to drop before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VSGmz5frZqzbpQXJxxuqTs
Visual evidenceCaptured with the dev-only fixture tools ( 1 + 2. Roster — nameless student and ungraded examRow
3. Course dashboard — the true-empty state
"No courses match this filter" (beside a lone, useless Light theme: after SpanishEvery string added here is defined in
Estudiante sin nombre · Sin calificar · Todavía no tienes cursos · Crear mi primer curso. Regression check
Media lives in |
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
Screenshots and A/B GIFs backing PR #575. Kept on the branch rather than master so the PR carries its own evidence; safe to drop before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VSGmz5frZqzbpQXJxxuqTs
2b62234 to
c2d21a3
Compare
|
Merged as Shipped
Verification at merge: Worth remembering. Widget screenshots are only as trustworthy as the port: the dev HTML embeds |
#578 (widget i18n/colour semantics) and #575 (missing-data display) both rewrote submission-grader/widget.tsx, so every hunk of this branch conflicted. Resolved by resetting the file to master and re-applying the #567 fix on top of the new structure rather than reconciling markers. What changed in the re-application: - The three new affordances are now translated. "Not graded", "Teacher adjusted", the low-confidence pill and the ungraded-points note all have en/es entries in this widget's STRINGS table, matching #578's convention; a missing Spanish string is a type error. - The em dash for an ungraded question is no longer hand-written. fmt.number() from shared/i18n.tsx already renders null as an em dash, so dropping the `?? 0` is now the entire fix at that call site and the dash follows the reader's locale like every other number. - Points and confidence go through fmt.number/fmt.percent, so es renders "confianza: 41 %" rather than a hardcoded English percentage. master's own shared/severity.ts landed the same rule this issue is about — "a draft course with no lessons has nothing to complete (neutral); a student who has completed nothing is at 0% and that is the worst case" — and #575 applied it to the roster while leaving `points_earned ?? 0` in this widget untouched. The re-applied fix is that doctrine reaching the per-question list, and isUngraded() now cites it. Kept from master unchanged: the inline "(82% confidence)" suffix for a confident score, so only a guess is promoted to a pill. Verified after the merge: mcp-server tsc --noEmit clean (the 4 pre-existing useCallTool errors this branch flagged are gone — master fixed them), 66/66 mcp-server tests, and the demo fixture re-shot in dark, light and es. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zt1kZKdcWAcsiJdBgnX3D









What
Three MCP widget states presented absent data as a value a teacher could act on — a raw user id where a name goes, an em dash where an ungraded exam goes, and a filter blamed for an empty course list that has no filter. Fixed, along with three adjacent defects found while fixing them.
Closes #568
Why
Each symptom is the same mistake: a nullable field rendered through a fallback that produces something plausible-looking instead of saying "we don't have this".
1. A student with no name rendered as their user id.
profiles.full_nameis nullable andfetchProfileNames()deliberately drops blanks, sostudent_name: nullis a normal value on every roster. The roster fell back tostudent_id.slice(0, 8)for the name andid.slice(0, 2)for the avatar —u-008andU-. That identifies nobody and reads as corrupted data.submission-graderhad the identical fallback.The honest fix is a placeholder, not a better-looking id. Resolving the real person would mean their email, which lives in
auth.usersand needsauth.admin.getUserById()— a service-role call these tools cannot make on the caller's RLS-scoped client. So the copy stays generic and, importantly, looks generic: muted italic, so a reviewer can see at a glance which rows are missing a name.2. "1 exam" beside "—". The em dash is this widget's glyph for "no data" everywhere else (progress %, last active), so reusing it for an exam awaiting a grade read as took an exam, scored nothing.
3. The empty course list blamed a filter. One branch served two situations. With
total === 0there is no filter to blame, the chips have collapsed to a loneAll, and a first-run teacher was given no way forward.Three adjacent defects, fixed here because leaving them makes the fix incoherent
4. The ungraded-exam state was unreachable from real data. In
lms_get_student_progress,exam_countwasex.n, andnwas incremented onlyif (s.score != null). An ungraded submission therefore vanished: the student read as "0 exams", not "1 exam, ungraded". Fixing only the widget would have shipped copy that never renders.aggregateExamSubmissions()now counts every submission and averages only the graded ones.5.
exam-submissionsdeclaredstudent_name: z.string()while its tool sendsnames.get(...) ?? null. A nameless student rendered as an empty cell.6.
lms_list_coursesreported a hardcodedtotal: 0on an empty page, discarding the realcount. With an offset past the end, a teacher who does have courses would gettotal: 0— and, after this change, be shown the "create your first course" screen. Fixing the empty state is what made this latent bug user-visible.How to QA
No database, RLS, auth or migration surface is touched, so no
db:resetis needed — these are widget and MCP-tool changes, verified through the dev-only fixture tools.cd mcp-server npm install MCP_DEMO_WIDGETS=1 npx mcp-use dev --port 3009u-008has no name; Grace Okoye has one ungraded exam):·avatar — nou-008, noU-— and Grace Okoye reading Ungraded over "1 exam", not—.Allchip. Repeat with--theme light.lms_demo_course_dashboard variant=defaultin the inspector and click a status chip; every chip matches at least one course, so the grid should never empty out. The "No courses match this filter" branch is what catches a chip that matches nothing, with a Show all N courses reset.lms_demo_exam_submissions variant=defaultandlms_demo_submission_grader— names render exactly as before (every fixture row has one).cd mcp-server && npm test && npm run build.Screenshots / GIF
Posted as a comment below — before/after for the roster and the course-dashboard empty state, in both themes and in Spanish, plus two A/B GIFs.
Checklist
npm run typecheckandnpm run test:unitpass — run asmcp-server's ownnpm run build(which ends in a full type check) andnpm test: 66 tests pass (10 of them new here; the rest arrived with feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload data, card alignment (#570) #578). The root-package scripts do not covermcp-server/, which is not an npm workspace.npm run buildpasses — inmcp-server/; 18 widgets built, type check clean.tenant_id— no query was added or changed in shape;lms_list_courseskeeps its existing.eq("tenant_id", …)and only the reportedtotalchanged.src/tool-policy.ts; that gating is untouched.isPendingbranches are unchanged; this PR adds the missing empty and absent-value states.messages/en.jsonandmessages/es.json— equivalent done, different file. MCP widgets render outside the Next.js app and have no access tonext-intl; since MCP widget UX polish: i18n, colour semantics, CTAs, unused payload data, card alignment #570 / PR feat(mcp): widget UX polish — i18n, colour semantics, CTAs, dead payload data, card alignment (#570) #578 they carry their ownSTRINGStables (resources/shared/i18n.tsx). Every string this PR adds — the placeholder name, "Ungraded", and all four empty states — is defined in bothenandesand verified in a Spanish render (below).Reviewer notes
exam_countchanges meaning — from "graded submissions" to "submissions". It is read only by this widget and this tool's own summary line; no sort, no at-risk rule, and no other tool consumes it. Worth a look because it changes what a number means, not just how it is formatted.aggregateExamSubmissions()andstudent-display.tsdid not exist before this branch. What they pin down is the behaviour the old code provably lacked: the old reducer created its accumulator but only ever incremented on a non-null score, so the ungraded-only case came out asexam_count: 0— the assertion ofsubmitted: 1could not have passed against it.submission-grader/widget.tsxis also touched by submission-grader: ungraded questions render as 0, overrides are invisible, AI confidence is decoration #567 / PR fix(mcp): submission-grader misreports what is graded — ungraded zeros, invisible overrides, decorative confidence (#567) #574. The change here is one line (the raw-id fallback) and is independent of that PR's?? 0work, but the two will want a look if they land close together.totalit was unreachable, and with the old copy it would have blamed a filter for a pagination overshoot.Rebased onto master 2026-07-28
PR #578 (#570, widget UX polish) and PR #576 landed first and touch all four of these widgets, so this branch was rebased rather than merged. Three things changed in the process, all visible in the diff:
STRINGStables, sounnamedStudent,ungraded, and the four empty states are defined inenandesinstead of being hardcoded English.studentDisplayName(name, unnamedLabel?)takes the placeholder as an argument, defaulting to English. The placeholder is a widget-owned string and follows the reader's locale; a realfull_nameis database content and is still rendered verbatim.fmt.percent+textClassfor a real average and only overrides them for the ungraded state.Re-verified after the rebase:
npm test66 passed,npm run buildtype check clean, and the roster and dashboard re-shot inenandes(screenshots in the comment below refreshed).