Skip to content

feat: add resumable local Zen quiz progress - #164

Merged
TJZine merged 3 commits into
stagingfrom
v1.5.2
Jul 23, 2026
Merged

feat: add resumable local Zen quiz progress#164
TJZine merged 3 commits into
stagingfrom
v1.5.2

Conversation

@TJZine

@TJZine TJZine commented Jul 23, 2026

Copy link
Copy Markdown
Owner

📋 Description

🔗 Related Issues

🧪 Type of Change

  • 🐛 Bug fix (non-breaking change fixing an issue)
  • ✨ New feature (non-breaking change adding functionality)
  • 💥 Breaking change (fix or feature causing existing functionality to change)
  • 📝 Documentation update
  • 🎨 Style update (formatting, renaming)
  • ♻️ Code refactoring (no functional changes)
  • ⚡ Performance improvement
  • ✅ Test update

📸 Screenshots

Before After

🧪 How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing
  • E2E tests

Test Configuration:

  • Device:
  • Browser:
  • Node version:

✅ Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally
  • Any dependent changes have been merged

📝 Additional Notes

Summary by CodeRabbit

  • New Features

    • Standard Zen quizzes can now save progress locally on the device and offer resume options after reloads or offline exits.
    • Dashboard cards indicate when a quiz has resumable progress and provide a “Continue Quiz” action.
    • Completion safely saves results and removes the associated draft.
    • Added safeguards for outdated quizzes, completed attempts, and conflicting activity in another tab.
  • Bug Fixes

    • Improved dashboard featured-card layout, loading states, theme styling, and reduced-motion behavior.
  • Documentation

    • Added architecture and implementation plans describing local Zen draft persistence, limitations, reconciliation, and recovery behavior.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cert-prep-ai Ready Ready Preview, Comment Jul 23, 2026 6:16am

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Standard-Zen draft persistence

Layer / File(s) Summary
Draft contract and persistence boundary
src/types/zenDraft.ts, src/db/zenDrafts.ts, src/db/dbInstance.ts, src/db/results.ts, docs/ARCHITECTURE.md
Adds validated, user/quiz-keyed IndexedDB drafts with revision and writer ownership checks, compatibility assessment, and atomic result finalization.
Draft-enabled session lifecycle
src/components/quiz/hooks/*, src/components/quiz/ZenQuizContainer.tsx, src/app/quiz/[id]/zen/page.tsx, src/stores/quizSessionStore.ts
Enables drafts only for standard Zen, hydrates explicit resume choices, autosaves progress, handles conflicts, and flushes drafts before exit or completion.
Dashboard resume indicators and layout
src/hooks/useDatabase.ts, src/components/dashboard/*, src/app/globals.css
Loads draft compatibility statuses, gates dashboard readiness, and renders featured/resumable quiz cards with updated skeleton and theme styling.
Local cleanup and import boundaries
src/lib/dataExport.ts, tests/e2e/helpers/db.ts, tests/e2e/offline-sync.spec.ts
Includes drafts in replacement imports, deletion purges, database cleanup, and offline test setup without adding synchronization.
Draft behavior verification
tests/unit/db/*, tests/unit/components/quiz/*, tests/unit/components/dashboard/*, tests/e2e/zen-draft.spec.ts
Covers migration, validation, concurrency, hydration, resume/restart flows, completion rollback, dashboard rendering, offline continuation, and excluded modes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.72% 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
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: local resumable Zen quiz progress via persisted drafts.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch v1.5.2

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
src/components/quiz/ZenQuizContainer.tsx (1)

164-186: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Draft-flush failure during completion strands the user with no retry path.

When flushDraft(true) fails inside the completion effect, handleSessionComplete is never called, so saveError never becomes true and the existing "We couldn't save your results" retry UI (in quizContent) never renders. Since pauseTimer() already fired, seconds stops changing, and the effect (keyed on seconds) won't re-run — the user is left on the "Initializing quiz session..." screen (lines 404-436) with, at best, a static persistenceNotice banner and no retry button. handleExit handles the same failure with an explicit toast (line 196-199); the completion path doesn't.

🛠️ Minimal mitigation (toast) — a full fix also needs a retry action reachable from the initializing branch
       void (async (): Promise<void> => {
         if (draftEligible && !(await flushDraft(true))) {
           throw new Error("Draft flush failed before result creation.");
         }
         await handleSessionComplete(elapsedSeconds);
       })().catch(() => {
         hasSavedResultRef.current = false;
+        addToast(
+          "error",
+          "Your latest progress was not saved. Please try finishing the quiz again.",
+        );
       });

Also applies to: 404-436

🤖 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/components/quiz/ZenQuizContainer.tsx` around lines 164 - 186, Update the
completion flow in the effect around flushDraft and handleSessionComplete so a
draft-flush failure is surfaced through the existing save-error/retry mechanism
or an explicit user-facing toast, rather than only resetting hasSavedResultRef.
Ensure the initializing branch around quizContent also exposes a reachable retry
action after failure, allowing completion to be attempted again while preserving
the paused timer state.
🧹 Nitpick comments (5)
tests/e2e/zen-draft.spec.ts (1)

109-122: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Share the runtime cache name instead of duplicating it.

public/sw.js already defines RUNTIME_CACHE = "certprep-runtime-v5", but tests/e2e/zen-draft.spec.ts repeats the literal. Move this to a shared test/source constant so the cache-version bump stays in one place.

🤖 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 `@tests/e2e/zen-draft.spec.ts` around lines 109 - 122, Replace the duplicated
"certprep-runtime-v5" literal in the cache assertion within the zen draft test
with a shared constant derived from the existing RUNTIME_CACHE definition in
public/sw.js. Expose or reuse that constant through the project’s established
shared test/source mechanism so future cache-version updates require changing
only one value.
tests/unit/components/quiz/ZenQuizContainer.test.tsx (1)

218-229: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for the sessionKeyMappings draft-disabling branch.

Per ZenQuizContainer.tsx, draftEligible also requires sessionKeyMappings === null. The it.each matrix covers sessionKind, isSmartRound, isTopicStudy, isSRSReview, isInterleaved but omits a case with sessionKeyMappings set to a non-null value.

Suggested addition
   it.each([
     ["remixed Zen", { sessionKind: "remixed_zen" as const }],
     ["Smart Round", { isSmartRound: true }],
     ["Topic Study", { isTopicStudy: true }],
     ["SRS Review", { isSRSReview: true }],
     ["Interleaved Practice", { isInterleaved: true }],
+    ["remixed key mappings", { sessionKeyMappings: { a: "a" } }],
   ])("never enables standard drafts for %s", (_label, props) => {
🤖 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 `@tests/unit/components/quiz/ZenQuizContainer.test.tsx` around lines 218 - 229,
Add a `sessionKeyMappings` case to the existing “never enables standard drafts”
`it.each` matrix in the ZenQuizContainer tests, using a non-null mapping value,
and assert it produces `draftEligible: false` like the other disabling
conditions.
tests/unit/components/dashboard/QuizCard.test.tsx (1)

82-100: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test title implies exclusivity but only covers the positive case.

"shows Continue Quiz only when a compatible local draft exists" doesn't assert the negative case (no hasResumableDraft/false → link absent). Consider adding that assertion to actually validate the "only when" claim.

Suggested addition
+  it("hides Continue Quiz when no resumable draft exists", () => {
+    render(
+      <QuizCard quiz={quiz} stats={attemptedStats} onStart={vi.fn()} onDelete={vi.fn()} />,
+    );
+    expect(
+      screen.queryByRole("link", { name: "Continue Quiz" }),
+    ).not.toBeInTheDocument();
+  });
🤖 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 `@tests/unit/components/dashboard/QuizCard.test.tsx` around lines 82 - 100,
Extend the “shows Continue Quiz only when a compatible local draft exists” test
to also render QuizCard without hasResumableDraft (or with it set to false) and
assert the “Continue Quiz” link is absent, while preserving the existing
positive-case assertions.
tests/unit/components/quiz/hooks/useQuizSession.test.tsx (1)

34-46: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

No test exercises useQuizSession's draft field mapping.

The new useZenDraftSession mock is wired in but no test in this file passes draftEligible: true or asserts that draftDecision/draftSaveStatus/resumeDraft/etc. are correctly derived from useZenDraftSession's return values. This mapping is exercised indirectly via ZenQuizContainer.test.tsx, but a direct unit test here would pin down this hook's specific contract.

🤖 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 `@tests/unit/components/quiz/hooks/useQuizSession.test.tsx` around lines 34 -
46, The useZenDraftSession mock is present, but useQuizSession’s draft field
mapping lacks direct coverage. Add a focused useQuizSession test with
draftEligible: true and non-default mocked draft values, then assert
draftDecision, draftSaveStatus, and the
resume/start-over/resume-as-new-attempt/flushDraft handlers are mapped from
useZenDraftSession correctly.
src/components/quiz/ZenQuizContainer.tsx (1)

412-420: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated exitDescription ternary.

The same draft-status-dependent description logic is repeated verbatim across both QuizLayout render branches. Extract once to avoid drift.

♻️ Suggested extraction
+  const exitDescription = draftEligible
+    ? draftSaveStatus === "conflict"
+      ? "A newer tab owns this saved draft. Exiting will not overwrite it."
+      : draftSaveStatus === "error"
+        ? "Your latest progress has not been saved. Close this dialog and retry after the device save succeeds."
+        : "Your progress is saved on this device. You can continue this quiz later."
+    : "Exiting ends this session. This mode does not save resumable progress.";
+
   if (isInitializing || !currentQuestion) {
     return (
       <QuizLayout
         ...
-        exitDescription={
-          draftEligible
-            ? draftSaveStatus === "conflict"
-              ? "..."
-              : draftSaveStatus === "error"
-              ? "..."
-              : "..."
-            : "..."
-        }
+        exitDescription={exitDescription}
       >

Also applies to: 446-454

🤖 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/components/quiz/ZenQuizContainer.tsx` around lines 412 - 420, Extract the
draft-status-dependent description currently assigned to exitDescription into a
single local value in the containing render scope, using draftEligible and
draftSaveStatus. Reuse that value in both QuizLayout branches, including the
branches around the existing exitDescription assignments, and preserve all
current messages and fallback behavior.
🤖 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/components/quiz/hooks/useZenDraftSession.ts`:
- Around line 337-390: The session-initialization effect around load must not
rerun for unrelated quiz-record reference changes, because its cleanup invokes
flushDraft and resetSession. Stabilize the quiz input returned by useQuiz or
narrow this effect’s dependencies to the initialization seed fields used by
resolveQuizHash and currentQuiz—quiz.id, quiz.quiz_hash, and
quiz.questions/version—while keeping title or option updates out of this
initialization lifecycle.

In `@src/hooks/useDatabase.ts`:
- Around line 205-223: Update the useLiveQuery callback in the zen draft status
flow to catch failures from assessZenDraft or related database reads and return
a usable empty or partial Map<string, ZenDraftCompatibility> instead of
rejecting. Preserve successful per-draft compatibility results and ensure
DashboardClient can finish loading when an individual status assessment fails.
- Around line 207-210: Update the quizzes query in the Promise.all within the
draft-status recomputation to filter by userId before converting results to an
array, matching the user-scoped query pattern used by zenDrafts and other
queries in useDatabase.

In `@tests/unit/components/dashboard/DashboardClient.empty-states.test.tsx`:
- Around line 206-265: Replace the internal debugging narration in the test case
“renders styled search empty state when category filter hides all quizzes” with
a single concise comment stating that zero results require combining the
selected category with a nonmatching search term, since category options always
correspond to existing quizzes. Keep the test behavior and assertions unchanged.

In `@tests/unit/components/quiz/hooks/useZenDraftSession.test.tsx`:
- Around line 106-116: Update the test around the IndexedDB wait and the
saveMessage assertion so it also waits for the hook state to re-render after
persistence completes. Poll or await result.current.saveMessage until it equals
"Saved on this device." instead of asserting it synchronously immediately after
the database wait.

---

Outside diff comments:
In `@src/components/quiz/ZenQuizContainer.tsx`:
- Around line 164-186: Update the completion flow in the effect around
flushDraft and handleSessionComplete so a draft-flush failure is surfaced
through the existing save-error/retry mechanism or an explicit user-facing
toast, rather than only resetting hasSavedResultRef. Ensure the initializing
branch around quizContent also exposes a reachable retry action after failure,
allowing completion to be attempted again while preserving the paused timer
state.

---

Nitpick comments:
In `@src/components/quiz/ZenQuizContainer.tsx`:
- Around line 412-420: Extract the draft-status-dependent description currently
assigned to exitDescription into a single local value in the containing render
scope, using draftEligible and draftSaveStatus. Reuse that value in both
QuizLayout branches, including the branches around the existing exitDescription
assignments, and preserve all current messages and fallback behavior.

In `@tests/e2e/zen-draft.spec.ts`:
- Around line 109-122: Replace the duplicated "certprep-runtime-v5" literal in
the cache assertion within the zen draft test with a shared constant derived
from the existing RUNTIME_CACHE definition in public/sw.js. Expose or reuse that
constant through the project’s established shared test/source mechanism so
future cache-version updates require changing only one value.

In `@tests/unit/components/dashboard/QuizCard.test.tsx`:
- Around line 82-100: Extend the “shows Continue Quiz only when a compatible
local draft exists” test to also render QuizCard without hasResumableDraft (or
with it set to false) and assert the “Continue Quiz” link is absent, while
preserving the existing positive-case assertions.

In `@tests/unit/components/quiz/hooks/useQuizSession.test.tsx`:
- Around line 34-46: The useZenDraftSession mock is present, but
useQuizSession’s draft field mapping lacks direct coverage. Add a focused
useQuizSession test with draftEligible: true and non-default mocked draft
values, then assert draftDecision, draftSaveStatus, and the
resume/start-over/resume-as-new-attempt/flushDraft handlers are mapped from
useZenDraftSession correctly.

In `@tests/unit/components/quiz/ZenQuizContainer.test.tsx`:
- Around line 218-229: Add a `sessionKeyMappings` case to the existing “never
enables standard drafts” `it.each` matrix in the ZenQuizContainer tests, using a
non-null mapping value, and assert it produces `draftEligible: false` like the
other disabling conditions.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ade4164-deaa-4331-aa90-8fef7e8ab1ca

📥 Commits

Reviewing files that changed from the base of the PR and between ee060d2 and 699400d.

📒 Files selected for processing (42)
  • docs/ARCHITECTURE.md
  • docs/plans/2026-07-22-local-zen-draft-persistence.md
  • src/app/globals.css
  • src/app/quiz/[id]/zen/page.tsx
  • src/components/dashboard/DashboardClient.tsx
  • src/components/dashboard/DashboardSkeleton.tsx
  • src/components/dashboard/QuizCard.tsx
  • src/components/dashboard/QuizGrid.tsx
  • src/components/dashboard/QuizSortControls.tsx
  • src/components/quiz/QuizLayout.tsx
  • src/components/quiz/ZenQuizContainer.tsx
  • src/components/quiz/hooks/useQuizPersistence.ts
  • src/components/quiz/hooks/useQuizSession.ts
  • src/components/quiz/hooks/useZenDraftSession.ts
  • src/db/dbInstance.ts
  • src/db/index.ts
  • src/db/results.ts
  • src/db/zenDrafts.ts
  • src/hooks/useDatabase.ts
  • src/hooks/useQuizSubmission.ts
  • src/lib/dataExport.ts
  • src/stores/quizSessionStore.ts
  • src/types/quiz.ts
  • src/types/zenDraft.ts
  • tests/e2e/helpers/db.ts
  • tests/e2e/offline-sync.spec.ts
  • tests/e2e/zen-draft.spec.ts
  • tests/unit/components/dashboard/DashboardClient.empty-states.test.tsx
  • tests/unit/components/dashboard/DashboardClient.test.tsx
  • tests/unit/components/dashboard/DashboardSkeleton.test.tsx
  • tests/unit/components/dashboard/QuizCard.test.tsx
  • tests/unit/components/dashboard/QuizGrid.test.tsx
  • tests/unit/components/dashboard/QuizSortControls.test.tsx
  • tests/unit/components/quiz/QuizLayout.test.tsx
  • tests/unit/components/quiz/ZenQuizContainer.test.tsx
  • tests/unit/components/quiz/hooks/useQuizSession.test.tsx
  • tests/unit/components/quiz/hooks/useZenDraftSession.test.tsx
  • tests/unit/db/standardZenCompletion.test.ts
  • tests/unit/db/zenDraftMigration.test.ts
  • tests/unit/db/zenDrafts.test.ts
  • tests/unit/stores/quizSessionStore.test.ts
  • tests/unit/styles/dashboard-theme-overrides.test.ts

Comment thread src/components/quiz/hooks/useZenDraftSession.ts
Comment thread src/hooks/useDatabase.ts
Comment thread src/hooks/useDatabase.ts Outdated
Comment thread tests/unit/components/quiz/hooks/useZenDraftSession.test.tsx
@TJZine

TJZine commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@TJZine TJZine changed the title Refine featured quiz card layout and persist local drafts feat: add resumable local Zen quiz progress Jul 23, 2026
@TJZine
TJZine merged commit 83442ea into staging Jul 23, 2026
8 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jul 26, 2026
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