Skip to content

fix: surface report failures, and consolidate the widget fallback - #64

Merged
skabou merged 3 commits into
mainfrom
fix/session-report-errors
Aug 15, 2026
Merged

fix: surface report failures, and consolidate the widget fallback#64
skabou merged 3 commits into
mainfrom
fix/session-report-errors

Conversation

@skabou

@skabou skabou commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Three related changes in the pathway pipeline: a failure that rendered as an empty class, the same defect in a second component, and a fallback policy written out thirteen times.

1. The session report showed an error as "no students yet"

sessionReport destructured only { data } from all four of its Supabase queries. Any failure — a permissions change, a renamed column, RLS — left data null, the function returned [], and the teacher saw:

No students have opened this pathway yet.

No log, no status code, nothing in the network tab. Given the roster tables are the ones with an outstanding revoke/RLS question, a permissions failure is both the most likely trigger and the one that looks most like ordinary emptiness.

Fixing the storage layer alone would have surfaced nothing. Both consumers already had error UI that could never fire:

fetch(`/api/pathway/sessions/${sessionId}/report`)
  .then((r) => r.json())      // a 500 with valid JSON sails straight through
  .then(setRows)
  .catch(() => setError('Failed to load report.'))   // only ever sees network errors

So all four layers had to change together:

layer change
supabase.ts 3 queries log and degrade; interactions throws
report/route.ts catches, logs, returns 500 with a generic message
SessionReport.tsx checks res.ok
SessionReportPage.tsx shared json() helper, checks res.ok on both fetches

Only the interactions query throws: every number in the report derives from those rows, so its failure is a wrong report rather than an empty one. The other three can legitimately return nothing, so they follow this file's existing convention for reads. The route keeps Postgres detail server-side, since error messages can carry schema and column names.

PathwaysDashboard had the identical dead error state and gets the same one-line fix.

2. One fallback policy instead of thirteen

Twelve of the sixteen widget generators each carried the same block — call the fallback generator, merge its note with a kind-specific message — and attemptStepWidget did it again.

Generators now return { widget: null, note } saying why they couldn't produce something usable, and attemptStepWidget makes the single fallback call. Kind-specific messages are unchanged. crossword's tail collapses to return normalize(spec), which is the shape it already had. All twelve drop fallbackWidgetKind and getWidgetGenerator from their imports.

This fixes a real bug. attemptStepWidget composed [substitutionNote, result.note] and dropped the fallback's own note, so on the paths where the outer fallback actually fired, that text was lost. All three now compose.

It also ended in widget as WidgetSpec, which quietly turned a null into a spec. That was survivable while twelve generators had their own fallback; now that swiper-flashcard is the only net, it throws instead — into generateStepWidget's existing retry, so a transient model failure gets a second attempt rather than rendering a blank step.

⚠️ No token saving, despite how it looks. I originally thought the two layers stacked into as many as six model calls per step. They don't: the twelve returned the fallback's non-null widget, so the outer branch could never fire for them — it was unreachable. Worst case was two calls before and remains two. The win is one policy instead of thirteen, the recovered note, and a contributor not needing to know to write that block.

swiper-flashcard is now the sole net. Checked: its normalize can't return null and its generate never returns a null widget, so it is genuinely terminal — but it is a single point of failure by design, which is what the new guard is there to make loud.

18 files, +91 / −117. pnpm lint 0 errors, pnpm build exit 0.

Same defect class, deliberately left out

Found while sweeping for the same two patterns, not included to keep this reviewable:

  • AssignToStudents.tsx:37 — a failed assignments fetch makes a student look unassigned, so a teacher can double-assign. Real consequence, but the per-item .catch may be intentional.
  • RosterPage.tsx:17, AssignToStudents.tsx:27 — guarded by Array.isArray, so a 500 silently shows an empty roster. Neither component has error UI, so fixing means designing one.
  • 10 storage reads drop error, notably loadProfile, fetchMasteryRollup and fetchRecentInteractions. Those three feed the student profile, so a failure yields a silently un-personalised pathway rather than a visible blank — the hardest kind to notice, and the best candidate for a follow-up.

learn/page.tsx:55 and SharedPathwayView.tsx:29 look like the same bug but already check response.ok; the remaining bare .catch(() => {}) sites are deliberate fire-and-forget.

sessionReport dropped the error from all four of its Supabase queries, so a permissions failure or a bad column returned [] and the UI rendered 'No students have opened this pathway yet.' A real failure was indistinguishable from a class that had not started.

Three queries now log and degrade; the interactions query throws, because every number in the report is derived from those rows -- a failure there is a wrong report rather than an empty one.

The route catches, logs, and returns 500 with a generic message, keeping Postgres detail out of the response. Both report components already had setError UI that could never fire: .catch() only sees network errors, so a 500 carrying valid JSON flowed straight into setRows. They now check res.ok, which is what actually makes the error visible.
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
interactive-learning-experiences Ready Ready Preview Aug 15, 2026 7:23pm

Request Review

Twelve of the sixteen widget generators each carried the same block: call the fallback generator, then merge its note with a kind-specific message. attemptStepWidget did the same thing again, so the fallback was written in thirteen places.

Generators now return { widget: null, note } saying why they could not produce something usable, and attemptStepWidget does the single fallback call. The kind-specific messages are unchanged; crossword's tail collapses to 
eturn normalize(spec), which is the shape it already had.

Fixes a real bug on the way: attemptStepWidget composed [substitutionNote, result.note] and dropped the fallback's own note, so on the four paths where the outer fallback actually fired that text was lost. All three now compose.

No token saving -- the two layers never stacked, because the twelve returned the fallback's non-null widget and the outer branch could not fire for them. This is about one policy instead of thirteen, and a contributor not having to know to write that block.
@skabou skabou changed the title fix: stop the session report showing an error as an empty class fix: surface report failures, and consolidate the widget fallback Aug 15, 2026
…hable

attemptStepWidget ended in widget as WidgetSpec, which quietly turned a null into a spec. That was survivable while twelve generators had their own fallback; now that swiper-flashcard is the only net it throws instead, into generateStepWidget's existing retry.

PathwaysDashboard had the same dead error state as the session report: .catch() only sees network failures, so a 500 with a JSON body flowed into setSessions and sessions.length ran on an object. It checks res.ok now.
@skabou
skabou merged commit 5af89d1 into main Aug 15, 2026
3 checks passed
@skabou
skabou deleted the fix/session-report-errors branch August 15, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant