From 2d5f10080876f64cf8bc30e4ad25a6ff3b203b12 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 16 Aug 2026 19:17:42 +0700 Subject: [PATCH] feat(quiz): perbaikan refresh quiz + enhancement UI kuis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QUIZ-01: beforeunload hanya kirim beacon (tanpa finalisasi state) sehingga Cancel dialog membatalkan reload dan quiz lanjut; beforeNavigate tidak lagi memfinalisasi untuk tipe 'leave' (full-page unload/reload); review disembunyikan untuk page_unload (display policy anti-eksploitasi) - QUIZ-02: opsi MCQ tampil 2-kolom grid (single-col untuk <=2 pilihan, fallback 1 kolom di mobile sempit) - QUIZ-03: hapus info "soal X dari Y / X dijawab" + progress bar dari workspace dan question view - QUIZ-04: opsi tinggi workspace mobile hide/30%/50%/70%/full dengan stepUp/stepDown untuk swipe & tombol resize - QUIZ-05: workspace flashcard hanya 2 tombol "Sudah Mengerti"/"Tidak Mengerti" (tracking understood), indikator di summary review 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../src/lib/components/WorkspaceHeader.svelte | 31 +++-- .../src/lib/services/quiz-integrity.test.ts | 5 +- frontend/src/lib/services/quiz-integrity.ts | 11 +- .../src/lib/services/quiz-session.test.ts | 33 ++++++ frontend/src/lib/services/quiz-session.ts | 11 ++ frontend/src/lib/types/quiz.ts | 2 + .../src/routes/lesson/[slug]/+page.svelte | 28 +++-- .../lesson/[slug]/QuizQuestionView.svelte | 10 +- .../src/routes/lesson/[slug]/QuizTab.svelte | 110 +++++++++++++----- frontend/src/routes/lesson/[slug]/lesson.css | 28 ++--- .../src/routes/lesson/[slug]/lesson.svelte.ts | 28 +++-- 11 files changed, 208 insertions(+), 89 deletions(-) diff --git a/frontend/src/lib/components/WorkspaceHeader.svelte b/frontend/src/lib/components/WorkspaceHeader.svelte index 106c5b8..da7c852 100644 --- a/frontend/src/lib/components/WorkspaceHeader.svelte +++ b/frontend/src/lib/components/WorkspaceHeader.svelte @@ -3,7 +3,7 @@ interface Props { isMobile: boolean; - mobileMode: 'hidden' | 'half' | 'full'; + mobileMode: 'hidden' | 'h30' | 'h50' | 'h70' | 'full'; activeTab: TabType; currentLanguage: string; hasInfo: boolean; @@ -37,6 +37,19 @@ let touchStartY = 0; + const MOBILE_MODES = ['hidden', 'h30', 'h50', 'h70', 'full'] as const; + type MobileMode = (typeof MOBILE_MODES)[number]; + + function stepUp(mode: MobileMode): MobileMode { + const idx = MOBILE_MODES.indexOf(mode); + return idx < MOBILE_MODES.length - 1 ? MOBILE_MODES[idx + 1] : mode; + } + + function stepDown(mode: MobileMode): MobileMode { + const idx = MOBILE_MODES.indexOf(mode); + return idx > 0 ? MOBILE_MODES[idx - 1] : mode; + } + const hasC = $derived(activeTabs?.includes('c') ?? false); const hasPython = $derived(activeTabs?.includes('python') ?? false); const hasVelxio = $derived(activeTabs?.includes('velxio') ?? false); @@ -56,13 +69,11 @@ function onSheetTouchEnd(e: TouchEvent) { const delta = e.changedTouches[0].clientY - touchStartY; if (delta < -60) { - // Swipe up → expand (pull sheet up) - if (mobileMode === 'hidden') mobileMode = 'half'; - else mobileMode = 'full'; + // Swipe up → expand (pull sheet up one level) + mobileMode = stepUp(mobileMode as MobileMode); } else if (delta > 60) { - // Swipe down → collapse (shrink downward) - if (mobileMode === 'full') mobileMode = 'half'; - else mobileMode = 'hidden'; + // Swipe down → collapse (shrink one level) + mobileMode = stepDown(mobileMode as MobileMode); } } @@ -70,7 +81,7 @@ activeTab = tab; if (lang) currentLanguage = lang; if (isMobile && mobileMode === 'hidden') { - mobileMode = 'half'; + mobileMode = 'h50'; } } @@ -119,8 +130,8 @@ {@render chromeTabs()}
- - + +
{:else if floating && !minimized} diff --git a/frontend/src/lib/services/quiz-integrity.test.ts b/frontend/src/lib/services/quiz-integrity.test.ts index 09f5bdd..d7ac4be 100644 --- a/frontend/src/lib/services/quiz-integrity.test.ts +++ b/frontend/src/lib/services/quiz-integrity.test.ts @@ -53,6 +53,10 @@ describe('shouldShowQuizReview', () => { expect(shouldShowQuizReview('focus_lost')).toBe(false); }); + it('page_unload → false (refresh tidak menampilkan pembahasan)', () => { + expect(shouldShowQuizReview('page_unload')).toBe(false); + }); + it('completed → true (penyelesaian normal menampilkan pembahasan)', () => { expect(shouldShowQuizReview('completed')).toBe(true); }); @@ -60,7 +64,6 @@ describe('shouldShowQuizReview', () => { it('exit penalti lain tetap menampilkan pembahasan', () => { expect(shouldShowQuizReview('user_exit')).toBe(true); expect(shouldShowQuizReview('spa_navigation')).toBe(true); - expect(shouldShowQuizReview('page_unload')).toBe(true); }); it('null (belum ada termination / kunjungan ulang) → true', () => { diff --git a/frontend/src/lib/services/quiz-integrity.ts b/frontend/src/lib/services/quiz-integrity.ts index 44f6e6b..cda5516 100644 --- a/frontend/src/lib/services/quiz-integrity.ts +++ b/frontend/src/lib/services/quiz-integrity.ts @@ -49,13 +49,14 @@ export function isFocusLossEvent( * Apakah pembahasan (review per-soal) boleh tampil di ringkasan setelah kuis * berakhir? * - * Strict policy: kuis yang dihentikan karena `focus_lost` TIDAK menampilkan - * pembahasan — siswa yang kehilangan fokus tidak mendapat bocoran jawaban. - * Alasan lain (`user_exit`, `spa_navigation`, `page_unload`, `completed`) - * tetap menampilkan pembahasan; `null` (belum ada termination) → tampil. + * Strict policy: kuis yang dihentikan karena `focus_lost` atau `page_unload` + * TIDAK menampilkan pembahasan — siswa yang kehilangan fokus / me-refresh + * halaman tidak mendapat bocoran jawaban. Alasan lain (`user_exit`, + * `spa_navigation`, `completed`) tetap menampilkan pembahasan; `null` + * (belum ada termination) → tampil. */ export function shouldShowQuizReview(reason: QuizTerminationReason | null): boolean { - return reason !== 'focus_lost'; + return reason !== 'focus_lost' && reason !== 'page_unload'; } /** diff --git a/frontend/src/lib/services/quiz-session.test.ts b/frontend/src/lib/services/quiz-session.test.ts index 14c5253..00d79cd 100644 --- a/frontend/src/lib/services/quiz-session.test.ts +++ b/frontend/src/lib/services/quiz-session.test.ts @@ -4,6 +4,7 @@ import { createQuizSession, answerQuestion, acknowledgeQuestion, + acknowledgeFlashcard, calculateQuizResult } from './quiz-session'; @@ -112,6 +113,38 @@ describe('acknowledgeQuestion', () => { }); }); +describe('acknowledgeFlashcard', () => { + it('marks acknowledged + understood true', () => { + const source = [makeQuestion('q-0', 'flashcard')]; + const session = createQuizSession(source, () => 0); + acknowledgeFlashcard(session, 'q-0', true); + expect(session.answers['q-0'].acknowledged).toBe(true); + expect(session.answers['q-0'].understood).toBe(true); + }); + + it('marks acknowledged + understood false (toggle)', () => { + const source = [makeQuestion('q-0', 'flashcard')]; + const session = createQuizSession(source, () => 0); + acknowledgeFlashcard(session, 'q-0', true); + acknowledgeFlashcard(session, 'q-0', false); + expect(session.answers['q-0'].acknowledged).toBe(true); + expect(session.answers['q-0'].understood).toBe(false); + }); + + it('throwing on unknown question id', () => { + const source = [makeQuestion('q-0', 'flashcard')]; + const session = createQuizSession(source, () => 0); + expect(() => acknowledgeFlashcard(session, 'nope', true)).toThrow(/unknown/i); + }); + + it('allHandled tetap berdasarkan acknowledged, apapun understood-nya', () => { + const source = [makeQuestion('q-0', 'flashcard')]; + const session = createQuizSession(source, () => 0); + acknowledgeFlashcard(session, 'q-0', false); + expect(calculateQuizResult(session).allHandled).toBe(true); + }); +}); + describe('calculateQuizResult', () => { it('counts only MCQ in the denominator, flashcards excluded', () => { const source = [makeQuestion('q-0'), makeQuestion('q-1'), makeQuestion('q-2', 'flashcard')]; diff --git a/frontend/src/lib/services/quiz-session.ts b/frontend/src/lib/services/quiz-session.ts index cf0a64f..25bf2be 100644 --- a/frontend/src/lib/services/quiz-session.ts +++ b/frontend/src/lib/services/quiz-session.ts @@ -76,6 +76,17 @@ export function acknowledgeQuestion(session: QuizSession, questionId: string): v answer.acknowledged = true; } +/** + * Tandai flashcard sebagai handled. `understood` mencatat apakah siswa + * merasa mengerti (true) atau tidak (false). Kedua state = acknowledged. + */ +export function acknowledgeFlashcard(session: QuizSession, questionId: string, understood: boolean): void { + const answer = session.answers[questionId]; + if (!answer) throw new Error(`Unknown question id: ${questionId}`); + answer.acknowledged = true; + answer.understood = understood; +} + export function calculateQuizResult(session: QuizSession): QuizResult { let totalMcq = 0; let correctMcq = 0; diff --git a/frontend/src/lib/types/quiz.ts b/frontend/src/lib/types/quiz.ts index 0525993..01ec224 100644 --- a/frontend/src/lib/types/quiz.ts +++ b/frontend/src/lib/types/quiz.ts @@ -21,4 +21,6 @@ export interface QuizAnswer { selectedOptionId: string | null; acknowledged: boolean; isCorrect: boolean; + /** Flashcard — apakah siswa merasa mengerti materi (true/false). Opsional. */ + understood?: boolean; } diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 7aea8a2..bd7a518 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -158,11 +158,17 @@ import QuizQuestionView from './QuizQuestionView.svelte'; }; }); - beforeNavigate(() => { + beforeNavigate((nav) => { lessonContext.set(null); - // Quiz integrity: submit with penalty on SPA navigation - if (mgr.isQuizMode) { + // Quiz integrity: submit with penalty on SPA navigation. + // JANGAN finalisasi untuk tipe 'leave' (full-page unload / reload): + // SvelteKit memanggil beforeNavigate dengan type 'leave' saat event + // beforeunload, dan di situ user masih bisa membatalkan dialog + // (F5 → Cancel). Finalisasi untuk 'leave' ditangani handler + // beforeunload yang hanya mengirim beacon — jadi quiz tetap hidup + // bila user membatalkan reload. + if (mgr.isQuizMode && nav.type !== 'leave') { mgr.submitQuiz('spa_navigation'); // fire-and-forget, async } @@ -178,11 +184,12 @@ import QuizQuestionView from './QuizQuestionView.svelte'; onMount(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (mgr.isQuizMode) { - // Finalisasi attempt via sendBeacon SYNCHRONOUS (page_unload) — - // menggantikan beacon track-progress lama agar satu attempt - // tercatat di endpoint atomic. Tidak mengandalkan await. - mgr.terminateQuiz('page_unload'); + if (mgr.isQuizMode && !mgr.quizFinalized) { + // Kirim beacon sebagai backup (skor partial tercatat di DB) + // TANPA memfinalisasi state — user masih bisa membatalkan dialog. + // Jika user cancel, quiz lanjut di memory; attempt_id sudah + // terpakai di DB (idempotent policy: retry tidak menimpa skor). + mgr.sendQuizBeacon('page_unload'); // Trigger browser confirm dialog e.preventDefault(); @@ -237,7 +244,6 @@ import QuizQuestionView from './QuizQuestionView.svelte'; question={mgr.currentQuizQuestion} currentIndex={mgr.quizCurrentIndex} totalCount={mgr.quizTotalCount} - answeredCount={mgr.quizAnsweredCount} onExit={() => { mgr.submitQuiz(); }} @@ -259,7 +265,9 @@ import QuizQuestionView from './QuizQuestionView.svelte'; class:floating-hidden={float.floating && float.minimized && !mgr.isMobile} class:mobile-sheet={mgr.isMobile} class:mobile-hidden={mgr.isMobile && mgr.mobileMode === 'hidden'} - class:mobile-half={mgr.isMobile && mgr.mobileMode === 'half'} + class:mobile-h30={mgr.isMobile && mgr.mobileMode === 'h30'} + class:mobile-h50={mgr.isMobile && mgr.mobileMode === 'h50'} + class:mobile-h70={mgr.isMobile && mgr.mobileMode === 'h70'} class:mobile-full={mgr.isMobile && mgr.mobileMode === 'full'} class:editor-locked={mgr.data?.locked} style={float.style}> diff --git a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte index 5ba7422..f11d281 100644 --- a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte @@ -5,25 +5,17 @@ question: QuizQuestion | null; currentIndex: number; totalCount: number; - answeredCount: number; onExit: () => void; } - let { question, currentIndex, totalCount, answeredCount, onExit }: Props = $props(); + let { question, currentIndex, totalCount, onExit }: Props = $props();
Soal {currentIndex + 1} dari {totalCount} - {answeredCount} dijawab
- {#if question}
diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index b3a072b..8126ee3 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -124,6 +124,10 @@
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaann tidak dapat diulang.
+ {:else if mgr.quizTerminationReason === 'page_unload'} +
+ Kuis dihentikan karena halaman di-refresh atau ditutup. Skor disimpan dan percobaan tidak dapat diulang. +
{/if} {@const correct = result.correctMcq}

Skor: {correct} / {totalMcq}

@@ -196,6 +200,8 @@
{@html q.back ?? ''}
{#if !a.acknowledged}

Tidak ditandai dipahami.

+ {:else if a.understood === false} +

❓ Siswa tidak mengerti materi ini.

{/if} {/if} {#if q.explanation} @@ -211,7 +217,7 @@ {/if} {/if} - {#if storedAttempt && !result && showReview && reviewQuestions.length > 0} + {#if storedAttempt && !result && shouldShowQuizReview(storedAttempt.termination_reason) && reviewQuestions.length > 0}

Pembahasan (tersimpan)

{#each reviewQuestions as q, i} @@ -282,21 +288,10 @@
{:else if questions.length > 0}
-
- Pertanyaan {mgr.quizCurrentIndex + 1} dari {questions.length} - {mgr.quizAnsweredCount} dijawab -
-
-
-
-
{#if currentQuestion?.type === 'mcq'}

Pilih jawabanmu

-
+
{#each currentQuestion.options ?? [] as option, i} +
{/if} @@ -406,15 +407,17 @@ .alert-info { background: rgba(13, 110, 253, 0.1); border: 1px solid var(--color-primary); color: var(--color-primary); } .alert-danger { background: rgba(233, 236, 239, 0.5); border: 1px solid var(--color-danger, #dc3545); color: var(--color-danger, #dc3545); } - .quiz-progress { font-size: 0.85rem; color: var(--color-text-muted); } - .quiz-progress-answered { margin-left: 0.75rem; } - .progress-bar-bg { height: 6px; background: var(--color-border); border-radius: 3px; margin-top: 0.5rem; overflow: hidden; } - .progress-bar-fill { height: 100%; background: var(--color-primary, #339af0); transition: width 0.3s ease; } - .answer-sheet { flex: 1; display: flex; flex-direction: column; gap: 1rem; } .answer-sheet-label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); font-weight: 700; } - .options-grid { display: flex; flex-direction: column; gap: 0.75rem; } - .option-btn { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--color-bg); border: 2px solid var(--color-border); border-radius: 12px; cursor: pointer; text-align: left; transition: all 0.2s; font-size: 1rem; color: var(--color-text); } + .options-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + .options-grid.single-col { + grid-template-columns: 1fr; + } + .option-btn { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.85rem; background: var(--color-bg); border: 2px solid var(--color-border); border-radius: 12px; cursor: pointer; text-align: left; transition: all 0.2s; font-size: 1rem; color: var(--color-text); align-self: stretch; } .option-btn:hover { border-color: var(--color-primary); background: var(--color-bg-secondary); } .option-btn.selected { border-color: var(--color-primary); background: var(--color-bg-secondary); box-shadow: 0 0 0 2px rgba(51, 154, 240, 0.15); } .option-marker { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; font-weight: 700; font-size: 0.9rem; flex-shrink: 0; color: var(--color-text); } @@ -423,8 +426,52 @@ .option-text :global(img) { max-width: 100%; height: auto; border-radius: 4px; display: block; margin: 0.5rem 0; } .answer-hint { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; } - .flashcard-ack-view { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; text-align: center; flex: 1; padding: 2rem 1rem; background: var(--color-bg-secondary); border: 2px dashed var(--color-border); border-radius: 16px; } - .ack-btn.acknowledged { background: var(--color-success); } + .flashcard-ack-view { + display: flex; + flex-direction: row; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; + padding: 1rem 0; + } + .btn-flashcard-ok { + flex: 1; + min-width: 120px; + max-width: 200px; + padding: 0.8rem 1rem; + border-radius: 10px; + border: 2px solid var(--color-success, #40c057); + background: transparent; + color: var(--color-success, #40c057); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.15s; + } + .btn-flashcard-ok:hover, + .btn-flashcard-ok.selected { + background: var(--color-success, #40c057); + color: white; + } + .btn-flashcard-no { + flex: 1; + min-width: 120px; + max-width: 200px; + padding: 0.8rem 1rem; + border-radius: 10px; + border: 2px solid var(--color-warning, #fab005); + background: transparent; + color: var(--color-warning, #fab005); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.15s; + } + .btn-flashcard-no:hover, + .btn-flashcard-no.selected { + background: var(--color-warning, #fab005); + color: white; + } .quiz-navigator { display: flex; flex-wrap: wrap; gap: 0.4rem; } .nav-dot { width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--color-border); background: var(--color-bg); color: var(--color-text); font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.15s; } @@ -471,6 +518,7 @@ .review-option.correct-opt { border-color: var(--color-success); background: rgba(25, 135, 84, 0.08); } .review-option-text :global(img) { max-width: 100%; height: auto; border-radius: 4px; } .review-unanswered { font-size: 0.8rem; color: var(--color-danger); font-style: italic; margin: 0.5rem 0; } + .review-not-understood { font-size: 0.8rem; color: var(--color-warning, #fab005); font-style: italic; margin: 0.5rem 0; } .review-hidden-note { font-size: 0.9rem; color: var(--color-text-muted); padding: 1rem; background: var(--color-bg-secondary); border: 1px dashed var(--color-border); border-radius: 8px; margin-top: 1rem; text-align: center; } .review-back { padding: 0.75rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; font-size: 0.9rem; } .explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; text-align: left; margin-top: 0.5rem; } @@ -482,6 +530,12 @@ .breakdown-item.unmastered { flex-basis: 100%; justify-content: center; } .unmastered-topic { font-size: 0.8rem; background: rgba(220, 53, 69, 0.1); color: var(--color-danger); padding: 0.15rem 0.5rem; border-radius: 6px; margin: 0.15rem; display: inline-block; } + @media (max-width: 400px) { + .options-grid { + grid-template-columns: 1fr; + } + } + @media (max-width: 600px) { .option-btn { padding: 0.75rem; font-size: 0.95rem; } .quiz-container { padding: 1rem; } diff --git a/frontend/src/routes/lesson/[slug]/lesson.css b/frontend/src/routes/lesson/[slug]/lesson.css index 604a096..3ae0907 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.css +++ b/frontend/src/routes/lesson/[slug]/lesson.css @@ -211,9 +211,19 @@ .editor-area.mobile-hidden { height: 52px; } +/* Backward compat — tidak dipakai lagi (digantikan mobile-h30/h50/h70). */ .editor-area.mobile-half { height: 60vh; } +.editor-area.mobile-h30 { + height: 30vh; +} +.editor-area.mobile-h50 { + height: 50vh; +} +.editor-area.mobile-h70 { + height: 70vh; +} .editor-area.mobile-full { height: 100dvh; border-radius: 0; @@ -447,11 +457,6 @@ color: var(--color-primary); } -.quiz-question-answered { - color: var(--color-text-muted); - margin-left: auto; -} - .quiz-question-header .btn-exit-quiz { background: none; border: none; @@ -462,19 +467,6 @@ padding: 0; } -.quiz-question-progress { - height: 6px; - background: var(--color-border); - border-radius: 3px; - overflow: hidden; -} - -.quiz-question-progress .progress-bar-fill { - height: 100%; - background: var(--color-primary, #339af0); - transition: width 0.3s ease; -} - .quiz-question-prose { line-height: 1.7; font-size: 1.05rem; diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 3a21c71..063d734 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -19,7 +19,7 @@ import type { QuizQuestion, QuizAnswer } from '$types/quiz'; import { createQuizSession, answerQuestion, - acknowledgeQuestion, + acknowledgeFlashcard, calculateQuizResult, type QuizSession, type QuizResult @@ -71,7 +71,7 @@ export class LessonManager { showSolution = $state(false); activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart' | 'deploy' | 'quiz'>('info'); showCelebration = $state(false); - mobileMode = $state<'hidden' | 'half' | 'full'>('hidden'); + mobileMode = $state<'hidden' | 'h30' | 'h50' | 'h70' | 'full'>('hidden'); isMobile = $state(false); // Refs (set from component) @@ -291,7 +291,7 @@ export class LessonManager { this.activeTab = 'quiz'; // On mobile the tab panel is a bottom sheet — open it so the answer // sheet is visible while the question stays in the content area above. - if (this.isMobile) this.mobileMode = 'half'; + if (this.isMobile) this.mobileMode = 'h50'; } selectQuizOption(optionId: string) { @@ -299,9 +299,9 @@ export class LessonManager { if (q && this.quizSession) answerQuestion(this.quizSession, q.id, optionId); } - acknowledgeFlashcard() { + acknowledgeFlashcard(understood: boolean) { const q = this.currentQuizQuestion; - if (q && this.quizSession) acknowledgeQuestion(this.quizSession, q.id); + if (q && this.quizSession) acknowledgeFlashcard(this.quizSession, q.id, understood); } goToQuizQuestion(index: number) { @@ -397,11 +397,23 @@ export class LessonManager { lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx)); } + /** + * Kirim beacon quiz tanpa memfinalisasi quiz session di memory. + * Digunakan oleh beforeunload — hanya backup; state quiz tidak diubah. + * Returns false bila quiz belum aktif atau sudah finalized. + */ + sendQuizBeacon(reason: QuizTerminationReason): boolean { + if (!this.quizSession || this.quizFinalized) return false; + const payload = this.buildQuizAttemptPayload(reason); + if (!payload) return false; + return quizAttemptBeacon(payload); + } + /** * Bangun payload attempt secara synchronous dari session & attempt state. * Returns null bila belum ada attempt/session atau token tidak tersedia. */ - private buildQuizAttemptPayload(reason: QuizTerminationReason): QuizAttemptSubmission | null { + buildQuizAttemptPayload(reason: QuizTerminationReason): QuizAttemptSubmission | null { if (!this.quizSession || !this.quizAttemptId) return null; const token = get(authToken) || localStorage.getItem('student_token') || ''; if (!token) return null; @@ -710,7 +722,7 @@ export class LessonManager { if (!this.isMobile) { float.floating = true; } else { - this.mobileMode = 'half'; + this.mobileMode = 'h50'; tick().then(() => { document.querySelector('.editor-area')?.scrollIntoView({ behavior: 'smooth' }); }); @@ -722,7 +734,7 @@ export class LessonManager { if (!this.isMobile) { float.floating = true; } else { - this.mobileMode = 'half'; + this.mobileMode = 'h50'; tick().then(() => { document.querySelector('.editor-area')?.scrollIntoView({ behavior: 'smooth' }); });