Quiz 'ALL' Mode Loading UX Improvement (Progress Bar + Prefetch + Error Toast)
SummaryAfter Sprint 228's lazy-load conversion (route 84.8→13.6kB), the 'ALL' mode (default) loads 10 category chunks in parallel via Promise.all while showing only a static SkeletonCard (no progress indicator); on a dynamic import failure it returns to idle (SP228 P2) but with no user-visible feedback. This sprint improves the loading UX along 3 axes: ① add a sonner toast (toast.error) to the start() catch to surface dynamic import failure as user-visible feedback (Toaster already mounted, 0 new infra) ② add an onProgress(loaded,total) callback to getRandomQuestions as the 5th positional arg (rng stays 4th, backward compatible) so each chunk load updates page state, visualized in a new QuizLoading component (quiz-local, reuses Progress+SkeletonCard → 0 new tokens) progress bar ③ add a new prefetchQuestions(category) export (fire-and-forget, error swallowed) warmed on the QuizStart Start button hover/focus (dynamic import cache reuse → reduced perceived loading). Includes a total=0 division guard. jest 1649 PASS (+14), global lines 88.07%/branches 79.39%, route /[locale]/quiz 13.9kB (+0.3kB, no lazy-load regression). Frontend only, merge ≠ live.
Goal
- After Sprint 228's per-category lazy-load conversion (
/[locale]/quizroute 84.8 → 13.6kB), improve the 'ALL' mode (default) loading UX along 3 axes (error feedback / progress indicator / prefetching). - Handle it with 0 new design tokens + 0 new infra + 0 new
components/ui(quiz-local only), without regressing Sprint 228's lazy-load (route Size). - Frontend only — merge ≠ live (post-redeploy live verification carries over to the
quiz-ui-verificationrunbook).
Background
Sprint 228 converted question data to dynamic import via CATEGORY_LOADERS at start time, removing it from the initial bundle (route 84.8→13.6kB). However, the 'ALL' mode (default) loads 10 category chunks in parallel via Promise.all, during which the user sees only a static SkeletonCard (no progress indicator). Also, when a dynamic import fails, the Sprint 228 P2 fix returns to idle, but with no user-visible feedback (on stale chunk / offline / CDN failure it silently returns to the start screen).
Sprint 229 closes this loading UX gap along 3 axes.
Decision
D0. Confirmed Decisions (user)
- Include all three axes — error feedback + progress indicator + prefetching.
- Loading UI = progress bar — reuse the existing Radix
Progress. - Prefetch trigger = Start button hover/focus.
D1. Error Feedback = sonner toast (Wave C)
- Add
toast.error(t('start.loadError'))to thestart()catch to reinforce Sprint 228 P2'sidlereturn with user-visible feedback. - sonner's
Toasteris already mounted inAppLayout, so 0 new infra.
D2. Loading Progress = onProgress Callback + QuizLoading Progress Bar (Wave A·B·C)
- Add
onProgress(loaded, total)togetRandomQuestionsas the 5th positional optional arg (rngstays 4th → backward compatible). Incrementloadedin each chunk's.thenand invoke the callback. page.tsxreceives progress asloadProgressstate and visualizes it in the newQuizLoading(quiz-local component — notcomponents/ui→ avoids the Palette trigger) progress bar.- Reuses the existing
Progress(Radix) +SkeletonCard→ 0 new design tokens.
D3. Prefetching = prefetchQuestions fire-and-forget (Wave A·D)
- Add a new
prefetchQuestions(category)export — fire-and-forget (result discarded, error swallowed). - On the
QuizStartStart buttononMouseEnter/onFocus, warm the currently selectedcategorychunk. Since dynamic imports are cached, the click reuses it → reduced perceived loading. - The actual load failure is surfaced as a toast not in prefetch but in the main path
start()'sgetRandomQuestions(prefetch swallows the error → user-path feedback is guaranteed by D1).
D4. total=0 Division Guard (Wave B)
QuizLoadingcomputestotal > 0 ? round(loaded / total * 100) : 0to safely handle the division in the initial snapshot (total not yet set).
Implementation
6 atomic commits total (start 834d07c):
| Commit | Wave | Content |
|---|---|---|
eae614c | A | data/quiz/index.ts — add onProgress 5th positional to getRandomQuestions + new prefetchQuestions(category) export (fire-and-forget) |
d34bb99 | B | new components/quiz/QuizLoading.tsx — Progress (Radix) + aria-live title + SkeletonCard, total=0 guard |
94eab78 | C | page.tsx — loadProgress state, onProgress wiring, start() catch toast.error, SkeletonCard → QuizLoading |
3608f64 | D | QuizStart.tsx — call prefetchQuestions on Start button hover/focus |
0ae0d87 | E | messages/{ko,en}/quiz.json — start.loadError + loading namespace (title/progress/progressAria) |
2c01053 | F | tests — index (onProgress ALL 0..10 · single 0..1 · prefetch reject swallow) · page (loading=QuizLoading progressbar · toast.error on reject regression) · QuizLoading new (6) · QuizStart (hover/focus/per-category prefetch spy) |
Verification
- tsc: 0 errors (all files).
- jest: 1649 PASS / 0 FAIL (Sprint 228 1635 → +14).
- Global coverage: lines 88.07% / branches 79.39% / statements 87.56% / functions 85.23% (gate 83/71/81/82 — pass).
- next lint: 0 errors.
- next build: "Compiled successfully".
/[locale]/quizroute Size 13.9kB (vs Sprint 228 13.6kB, +0.3kB — question data still split into an off-route on-demand chunk, no lazy-load regression). - i18n: ko/en 52 keys consistent.
- ADR gates: index count (sprint 167) / adr-en coverage (KR/EN 1:1) / adr-links 0 broken / doc-refs no broken
Critic Cross-Review
- Tool: Codex codex-cli (per-Wave Auto-Critic + final full-branch)
Final full-branch Critic: run separately by Oracle just before merge (full branch = authoritative review) — focusing on async progress callback consistency · prefetch error swallow · toast feedback · total=0 guard.
Overall verdict: ✅ Mergeable — jest 1649 PASS (+14), route Size 13.9kB (no lazy-load regression), coverage / i18n / ADR gates all pass.