Skip to content

Migrate Quizzes to Svelte 5 and TS (with minor improvements)#958

Merged
FyreByrd merged 8 commits into
mainfrom
feature/migrate-quizzes
May 14, 2026
Merged

Migrate Quizzes to Svelte 5 and TS (with minor improvements)#958
FyreByrd merged 8 commits into
mainfrom
feature/migrate-quizzes

Conversation

@FyreByrd
Copy link
Copy Markdown
Collaborator

@FyreByrd FyreByrd commented May 13, 2026

Rebase of #888 with some improvements.

Original PR comment from #888:

  • Replaces run() with effect() for reactivity
  • Uses $state and $derived for all dynamic values
  • Handles audio playback, quiz progression, and result saving
  • Ensures full client-side functionality without server code

Summary by CodeRabbit

  • Refactor

    • Consolidated quiz type handling and improved data loading for more reliable quizzes.
    • Reworked quiz page and state management for clearer question flow and score handling.
  • New / Improved UI

    • Enhanced audio playback sequencing, answer highlighting, and explanation display.
    • Cleaner start/stop routines and more consistent saved/locked/score states.
  • Style

    • Ensured dynamic grid column classes are preserved during CSS builds.

Review Change Stack

joslane and others added 5 commits May 13, 2026 11:33
- Replaces run() with effect() for reactivity
- Uses $state and $derived for all dynamic values
- Handles audio playback, quiz progression, and result saving
- Ensures full client-side functionality without server code
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

Warning

Rate limit exceeded

@FyreByrd has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 46 minutes and 38 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 070f84f0-d65c-446c-aff6-645a51b95643

📥 Commits

Reviewing files that changed from the base of the PR and between a413669 and 60f0c0b.

📒 Files selected for processing (1)
  • src/lib/data/quiz.ts
📝 Walkthrough

Walkthrough

A quiz type system is formalized in the config layer with new QuizExplanation, QuizAnswer, QuizQuestion, and Quiz types; duplicate type declarations are removed from the conversion module. The quiz page load function is updated to properly type quiz data and return computed passScore and display labels. The quiz component is refactored from legacy Svelte to runes-based state management with updated audio sequencing, UI state handling, and answer rendering logic using new snippet-based conditionals.

Changes

Quiz Type System & Component Migration

Layer / File(s) Summary
Quiz type definitions and config narrowing
config/index.d.ts, convert/convertBooks.ts
New quiz type hierarchy is introduced in the config layer (QuizExplanation, QuizAnswer, QuizQuestion, Quiz), BookConfig.quizFeatures is narrowed from any to `Record<string, string
Quiz page load function typing and data flow
src/routes/quiz/[collection]/[id]/+page.ts
The SvelteKit load function is retyped with PageLoad; config is cast to ScriptureConfig, quiz JSON URLs use typed eager globbing, book/collection lookups are optional, and the return object includes collection, displayLabel, and passScore computed from fetched quiz data.
Quiz component refactor to runes-based state management
src/routes/quiz/[collection]/[id]/+page.svelte
The component is refactored from legacy Svelte patterns to Svelte 5 runes with typed $props(), $derived(), $state(), and $effect(); helper functions for random selection and shuffling are added; state management for quiz progress, answers, and audio playback is rewritten with clearer start/stop routines; UI rendering for locked/score/result states is updated; answer rendering is switched to snippet-based layout with dynamic class selection based on correctness, clicked state, and highlight.
Quiz persistence and data helpers
src/lib/data/quiz.ts
Add ScriptureConfig usage, type the IndexedDB quizDB singleton, compute bookIndex from typed config in addQuiz, conditionally insert quiz records, and refine findQuiz/checkQuizAccess signatures and queries.
Tailwind safelist
tailwind.config.cjs
Add theme.safelist with /grid-cols-/ pattern to include dynamic grid column utilities during Tailwind build.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • sillsdev/appbuilder-pwa#841: Both PRs modify the quiz route load logic to resolve the quiz within the [collection]/[id] route and derive passScore from fetched quiz data.

Suggested reviewers

  • chrisvire

Poem

🐰 A rabbit hops through configs bright,
Types aligned and audio right,
Runes now guide each question's tune,
Answers shine beneath the moon,
Hooray — the quiz is snug and light!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: migrating quiz functionality to Svelte 5 and TypeScript, which aligns with the major file refactors across config, component, and data handling files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/migrate-quizzes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
src/routes/quiz/[collection]/[id]/+page.svelte (2)

124-126: 💤 Low value

Use the destructured collection for consistency.

collection is already destructured from $derived(data) on line 71 and is used everywhere else in this file (line 309). Reading data.collection here bypasses the local binding for no benefit and is slightly inconsistent.

♻️ Suggested change
     function getImageSource(image: string) {
-        return illustrations[`./${data.collection}-${quiz?.id}-${image}`] ?? '';
+        return illustrations[`./${collection}-${quiz?.id}-${image}`] ?? '';
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/quiz/`[collection]/[id]/+page.svelte around lines 124 - 126, The
getImageSource function uses data.collection directly instead of the local
destructured variable collection; update getImageSource (function name:
getImageSource) to reference the already-destructured collection variable
(replace data.collection with collection) so it is consistent with other usages
and uses the local binding when building the illustrations key
(illustrations[`./${collection}-${quiz?.id}-${image}`]).

284-291: 💤 Low value

Show displayLabel rather than the raw quizId in the locked banner.

quizId is the bare book id (e.g., Q01), while displayLabel is the user-facing name computed in +page.ts. Using quizId here makes the locked screen look technical/inconsistent with the unlocked navbar (which uses displayLabel via BookSelector).

🩹 Proposed fix
     {`#if` locked}
         <div class="quiz-locked">
-            <div class="quiz-locked-title">{quizId}</div>
+            <div class="quiz-locked-title">{displayLabel}</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/quiz/`[collection]/[id]/+page.svelte around lines 284 - 291, The
locked banner is showing the raw quizId; replace it with the user-facing
displayLabel computed in +page.ts so the locked view matches the navbar. In
+page.svelte, use the displayLabel provided by the page load (e.g.,
data.displayLabel or the local displayLabel prop) instead of {quizId}; if
displayLabel isn't already passed into the component, export/forward it from the
+page.ts load result so the template can render {displayLabel} (falling back to
quizId only if displayLabel is missing).
config/index.d.ts (1)

344-353: 💤 Low value

Avoid coupling UI state (clicked) to the config schema type.

clicked is a transient UI flag, not part of the persisted quiz config. Embedding it in QuizAnswer (which is also imported by convert/convertBooks.ts, an SFM→JSON converter) bleeds runtime UI concerns into the source-of-truth schema and makes the type lie about what's in *.json quiz assets. Consider deriving a UI-only type in the component instead.

♻️ Suggested split between schema and UI state
 export type QuizAnswer = {
     //\aw or \ar
     correct: boolean;
     text?: string;
     image?: string;
     audio?: string;
     explanation?: QuizExplanation;
-    // field is used extensively in UI, adding here for type-safety
-    clicked?: boolean;
 };

Then in +page.svelte:

type QuizAnswerUI = QuizAnswer & { clicked?: boolean };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@config/index.d.ts` around lines 344 - 353, The QuizAnswer type currently
includes a UI-only field clicked which couples transient UI state into the
persisted quiz schema; remove clicked from the exported QuizAnswer type and
instead create a derived UI type where needed (e.g., in +page.svelte declare
QuizAnswerUI = QuizAnswer & { clicked?: boolean }) and update any consumers
accordingly (notably convert/convertBooks.ts should keep using the pure
QuizAnswer for SFM→JSON conversion). Ensure no other modules rely on
QuizAnswer.clicked; if they do, switch those callers to use the UI-specific
type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/routes/quiz/`[collection]/[id]/+page.svelte:
- Line 77: The component never flips the local saved state so the {`#if` !saved}
gate around the {`#await` addQuiz(...)} can’t prevent duplicate writes; update the
flow so that after addQuiz resolves you set saved = true (or memoize the addQuiz
call) — locate the local saved variable (initialized via let saved =
$state(false)), the {`#await` addQuiz(...)} block and the resolution handling of
addQuiz and assign saved = true in the success branch (or implement a one-shot
helper/wrapper around addQuiz) so the await block only runs once per intended
save.
- Around line 90-94: The current conditional uses
book?.quizFeatures?.['shuffle-questions'] as a boolean so any non-empty string
(e.g. "false") will be truthy and trigger shuffle; update the check in the
questions initialization (const questions: QuizQuestion[] = $derived(...)) to
compare the feature string explicitly against the expected value (e.g. ===
'true' or === 'yes') before calling shuffle, so only the intended configured
value enables shuffling.
- Around line 1-19: The module script block uses TypeScript syntax (generics and
type annotations in getRandomItem<T> and shuffle<T>) but is not declared as
TypeScript; update the <script module> tag to include lang="ts" so Svelte treats
that block as TypeScript (i.e. change <script module> to <script module
lang="ts">) and then ensure getRandomItem and shuffle keep their existing
signatures/annotations.
- Around line 299-301: The replacement call is passing a number to
String.prototype.replace; change the second argument to an explicit string
(e.g., String(currentQuestionIdx) or `${currentQuestionIdx}`) where the template
uses {$t['Quiz_Score_Page_Message_After'].replace('%n%',
currentQuestionIdx)}—update that expression to stringify currentQuestionIdx so
the replace call satisfies TypeScript's expected string | function type.
- Around line 321-336: The cols calculation currently forces a boolean ternary
and discards numeric column values; change the expression so nullish coalescing
applies to currentQuestion.columns first (e.g. set cols =
currentQuestion.columns ?? (imgFormat ? 2 : 1)) so explicit numeric column
settings like 3+ are honored, and keep using the dynamic grid class
grid-cols-{cols}; additionally add a safelist entry in tailwind.config.cjs to
generate grid-cols-* classes (e.g. a pattern/safelist for
grid-cols-1..grid-cols-N or a regex like /grid-cols-(\d+)/) so Tailwind JIT
picks up the dynamic grid-cols-{cols} classes at build time.

In `@src/routes/quiz/`[collection]/[id]/+page.ts:
- Around line 26-30: The variable dependentQuizId may be undefined because
book.quizFeatures['access-after'] can be missing; update the logic around
dependentQuizId and checkQuizAccess to guard against undefined (or normalize to
null) before calling checkQuizAccess. Specifically, in the block guarded by
book?.quizFeatures?.['access-type'] === 'after', validate that
book.quizFeatures['access-after'] is a non-empty string (or provide a safe
default), only call checkQuizAccess(dependentQuizId) when dependentQuizId is
defined, and set locked appropriately (e.g., default to locked if the dependent
ID is missing), or change dependentQuizId's declared type to include undefined
and handle that case in checkQuizAccess usage.

---

Nitpick comments:
In `@config/index.d.ts`:
- Around line 344-353: The QuizAnswer type currently includes a UI-only field
clicked which couples transient UI state into the persisted quiz schema; remove
clicked from the exported QuizAnswer type and instead create a derived UI type
where needed (e.g., in +page.svelte declare QuizAnswerUI = QuizAnswer & {
clicked?: boolean }) and update any consumers accordingly (notably
convert/convertBooks.ts should keep using the pure QuizAnswer for SFM→JSON
conversion). Ensure no other modules rely on QuizAnswer.clicked; if they do,
switch those callers to use the UI-specific type.

In `@src/routes/quiz/`[collection]/[id]/+page.svelte:
- Around line 124-126: The getImageSource function uses data.collection directly
instead of the local destructured variable collection; update getImageSource
(function name: getImageSource) to reference the already-destructured collection
variable (replace data.collection with collection) so it is consistent with
other usages and uses the local binding when building the illustrations key
(illustrations[`./${collection}-${quiz?.id}-${image}`]).
- Around line 284-291: The locked banner is showing the raw quizId; replace it
with the user-facing displayLabel computed in +page.ts so the locked view
matches the navbar. In +page.svelte, use the displayLabel provided by the page
load (e.g., data.displayLabel or the local displayLabel prop) instead of
{quizId}; if displayLabel isn't already passed into the component,
export/forward it from the +page.ts load result so the template can render
{displayLabel} (falling back to quizId only if displayLabel is missing).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad8c757f-1d25-4b00-b7cf-8d9941a725a7

📥 Commits

Reviewing files that changed from the base of the PR and between a512e48 and 41e1313.

📒 Files selected for processing (4)
  • config/index.d.ts
  • convert/convertBooks.ts
  • src/routes/quiz/[collection]/[id]/+page.svelte
  • src/routes/quiz/[collection]/[id]/+page.ts

Comment thread src/routes/quiz/[collection]/[id]/+page.svelte Outdated
Comment thread src/routes/quiz/[collection]/[id]/+page.svelte
Comment thread src/routes/quiz/[collection]/[id]/+page.svelte
Comment thread src/routes/quiz/[collection]/[id]/+page.svelte
Comment thread src/routes/quiz/[collection]/[id]/+page.svelte
Comment thread src/routes/quiz/[collection]/[id]/+page.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/data/quiz.ts (1)

19-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix compound index key type from string to [string, string] — The 'collection, book' index is created from ['collection', 'book'] (line 34), so its key type should be a tuple of the indexed field types, not string. Declaring it as [string, string] aligns with the idb library's DBSchema typing for compound indexes, eliminates the type mismatch with the getAll([item.collection, item.book]) call at line 84, and allows you to remove the apologetic comment.

Proposed changes
 interface Quiz extends DBSchema {
     quiz: {
         key: number;
         value: QuizScore;
         indexes: {
-            'collection, book': string;
+            'collection, book': [string, string];
             date: number;
         };
     };
 }
-    // I have no clue how to make the type system happy here... -Aidan
     const result = await index.getAll([item.collection, item.book]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/data/quiz.ts` around lines 19 - 23, The compound index key in the
Quiz DB schema is incorrectly typed as string; update the index key type for
'collection, book' from string to a tuple [string, string] in the indexes
declaration to match the actual index created from ['collection', 'book'] and
the getAll([item.collection, item.book]) usage, and then remove the obsolete
comment that apologizes for the mismatch; this aligns the schema typing with the
idb DBSchema expectations and fixes the type error.
🧹 Nitpick comments (1)
src/lib/data/quiz.ts (1)

74-93: ⚡ Quick win

openQuiz() return type is now nullable, but only addQuiz guards against it.

After the typing change on line 26, openQuiz() resolves to ... | null, yet getQuiz, findQuiz, and checkQuizAccess call methods directly on the result. Under strictNullChecks these would be type errors; at runtime they would TypeError if quizDB ever stayed null (e.g., an openDB rejection on a prior call left state inconsistent). The cleanest fix is to make openQuiz itself return a non-null handle so every caller benefits.

♻️ Suggested change
 async function openQuiz() {
     if (!quizDB) {
         quizDB = await openDB<Quiz>('quiz', 1, {
             upgrade(db) {
                 const quizStore = db.createObjectStore('quiz', {
                     keyPath: 'date'
                 });
                 quizStore.createIndex('collection, book', ['collection', 'book']);

                 quizStore.createIndex('date', 'date');
             }
         });
     }
     return quizDB;
 }

You can then drop the if (!quiz) check in addQuiz and the bare quiz.* accesses in the other helpers become type-safe without further changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/data/quiz.ts` around lines 74 - 93, openQuiz() now returns a nullable
DB handle but callers (getQuiz, findQuiz, checkQuizAccess, addQuiz) call methods
directly; change openQuiz to always return a non-null DB handle (or throw on
failure) so its return type is non-nullable, update its signature accordingly,
and remove the redundant null-guard in addQuiz (the other helpers can keep
calling openQuiz() and use the DB methods without extra checks). Ensure
references to openQuiz, getQuiz, findQuiz, checkQuizAccess, and addQuiz are
updated to the new non-null return type so TypeScript strictNullChecks no longer
errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/data/quiz.ts`:
- Around line 58-68: The guard using if (bookIndex) is wrong because findIndex
returns 0 for the first match and -1 when not found; update the condition around
bookIndex (from the block that constructs nextItem and calls quiz.add('quiz',
nextItem)) to only proceed when bookIndex is a non-negative number (e.g.,
bookIndex !== undefined && bookIndex >= 0 or Number.isInteger(bookIndex) &&
bookIndex >= 0) so the first book (index 0) is accepted and missing-books (-1)
are rejected; keep the rest of the logic (spreading item, setting date and
bookIndex, calling quiz.add) unchanged.

In `@tailwind.config.cjs`:
- Line 6: The safelist entry only preserves base grid-cols-* classes; update the
safelist pattern configuration (the safelist array entry with pattern
/grid-cols-/) to include responsive variants so classes like md:grid-cols-4 are
kept; add a variants array such as ['sm','md','lg','xl','2xl'] (or the specific
responsive prefixes you use) to that safelist object so Tailwind won't purge
responsive grid-cols utilities.

---

Outside diff comments:
In `@src/lib/data/quiz.ts`:
- Around line 19-23: The compound index key in the Quiz DB schema is incorrectly
typed as string; update the index key type for 'collection, book' from string to
a tuple [string, string] in the indexes declaration to match the actual index
created from ['collection', 'book'] and the getAll([item.collection, item.book])
usage, and then remove the obsolete comment that apologizes for the mismatch;
this aligns the schema typing with the idb DBSchema expectations and fixes the
type error.

---

Nitpick comments:
In `@src/lib/data/quiz.ts`:
- Around line 74-93: openQuiz() now returns a nullable DB handle but callers
(getQuiz, findQuiz, checkQuizAccess, addQuiz) call methods directly; change
openQuiz to always return a non-null DB handle (or throw on failure) so its
return type is non-nullable, update its signature accordingly, and remove the
redundant null-guard in addQuiz (the other helpers can keep calling openQuiz()
and use the DB methods without extra checks). Ensure references to openQuiz,
getQuiz, findQuiz, checkQuizAccess, and addQuiz are updated to the new non-null
return type so TypeScript strictNullChecks no longer errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0a3bf1f1-cb32-4ef2-99a0-c38f9a27ec45

📥 Commits

Reviewing files that changed from the base of the PR and between 41e1313 and a413669.

📒 Files selected for processing (5)
  • config/index.d.ts
  • src/lib/data/quiz.ts
  • src/routes/quiz/[collection]/[id]/+page.svelte
  • src/routes/quiz/[collection]/[id]/+page.ts
  • tailwind.config.cjs
🚧 Files skipped from review as they are similar to previous changes (3)
  • config/index.d.ts
  • src/routes/quiz/[collection]/[id]/+page.ts
  • src/routes/quiz/[collection]/[id]/+page.svelte

Comment thread src/lib/data/quiz.ts
Comment thread tailwind.config.cjs
@FyreByrd FyreByrd merged commit 4d81b7d into main May 14, 2026
4 checks passed
@FyreByrd FyreByrd deleted the feature/migrate-quizzes branch May 14, 2026 14:46
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.

2 participants