diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index bdb7868..f09d821 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -173,6 +173,9 @@ export interface LessonProgressResponse { idempotent?: boolean; attempt_id?: string; message?: string; + is_done?: boolean; + state?: string; + composite_percent?: number | null; } export async function submitLessonProgress( diff --git a/frontend/src/lib/types/lesson.ts b/frontend/src/lib/types/lesson.ts index 66a45bf..aeff653 100644 --- a/frontend/src/lib/types/lesson.ts +++ b/frontend/src/lib/types/lesson.ts @@ -47,5 +47,6 @@ export interface LessonContent { evaluation_config: Record; quiz_data?: QuizQuestion[]; lesson_progress_status?: string; + has_quiz_attempt?: boolean; hex_content?: string; } diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index 1b3a12e..9519199 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -3,4 +3,4 @@ * Both Footer.svelte and service-worker.ts import from here. * Bump this number after every feature/bugfix release. */ -export const APP_VERSION = 29; +export const APP_VERSION = 30; diff --git a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte index 418f3a4..4a0092a 100644 --- a/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizQuestionView.svelte @@ -9,21 +9,28 @@ const questions = $derived(mgr.quizSession?.questions ?? []); + let submitting = $state(false); + function isAnswered(qId: string): boolean { const a = mgr.quizSession?.answers[qId]; if (!a) return false; return a.selectedOptionId !== null || a.acknowledged; } - function handleFinish() { - if (!mgr.quizAllAnswered) return; + async function handleFinish() { + if (!mgr.quizAllAnswered || submitting) return; if ( window.confirm( 'Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.' ) ) { - mgr.finishQuiz(); + submitting = true; + try { + await mgr.finishQuiz(); + } finally { + submitting = false; + } } } @@ -48,7 +55,7 @@ {/each} {/if} - + {#if mgr.quizCurrentQuestion} @@ -87,9 +94,9 @@ type="button" class="btn btn-success" onclick={handleFinish} - disabled={!mgr.quizAllAnswered} + disabled={!mgr.quizAllAnswered || submitting} > - Selesai Kuis + {submitting ? 'Menyimpan...' : 'Selesai Kuis'} {/if} diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index f58e2d2..87bc4d9 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -16,11 +16,19 @@ let { mgr }: Props = $props(); const isLoggedIn = $derived($authLoggedIn); + + // --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) --- + let storedAttempt: LessonProgressFetchResponse | null = $state(null); + let reviewQuestions: QuizQuestion[] = $state([]); + let reviewAnswers: Record = $state({}); + // Gate 2: Quiz already completed (one-attempt enforcement) const quizAlreadyCompleted = $derived( - !!mgr.data?.lesson_progress_status && + (!!mgr.data?.lesson_progress_status && mgr.data.lesson_progress_status !== 'not_started' && - mgr.data.lesson_progress_status !== '' + mgr.data.lesson_progress_status !== '') || + !!mgr.data?.has_quiz_attempt || + !!storedAttempt ); const currentQuestion = $derived(mgr.currentQuizQuestion); @@ -31,11 +39,6 @@ // pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban). const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason)); - // --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) --- - let storedAttempt: LessonProgressFetchResponse | null = $state(null); - let reviewQuestions: QuizQuestion[] = $state([]); - let reviewAnswers: Record = $state({}); - async function loadStoredAttempt() { const token = get(authToken) || localStorage.getItem('student_token') || ''; if (!token || !mgr.data) return; @@ -43,7 +46,7 @@ try { const resp = await fetchLessonProgress(lessonName, token); const storedAnswers = resp.answers ?? []; - if (resp.success && storedAnswers.length) { + if (resp.success && (storedAnswers.length > 0 || resp.attempt_id)) { storedAttempt = resp; // Rekonstruksi question+answer maps dari stored attempt agar // summary review per-soal bisa dirender setelah refresh. @@ -71,7 +74,7 @@ // Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open) onMount(() => { const r = result; - if (!r && quizAlreadyCompleted && !mgr.quizSession) { + if (!r && (quizAlreadyCompleted || mgr.data?.has_quiz_attempt) && !mgr.quizSession) { loadStoredAttempt(); } }); @@ -113,13 +116,24 @@ {#if quizAlreadyCompleted && !result} {#if storedAttempt} {@const s = storedAttempt} -

Nilai Anda: {s.attempt_score ?? s.quiz_score_total != null ? `${s.quiz_score_earned ?? '-'}/${s.quiz_score_total}` : mgr.data?.lesson_progress_status}

+ {@const earned = s.quiz_score_earned ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[0], 10) : null)} + {@const total = s.quiz_score_total ?? (s.attempt_score?.includes('/') ? parseInt(s.attempt_score.split('/')[1], 10) : null)} +

Nilai Anda: {s.attempt_score ?? (total != null ? `${earned ?? '-'}/${total}` : mgr.data?.lesson_progress_status)}

{#if s.attempt_started_at || s.attempt_status}
{#if s.attempt_status}Status: {s.attempt_status}{/if} {#if s.attempt_score}Skor: {s.attempt_score}{/if}
{/if} + {#if total != null && total > 0 && (earned ?? 0) / total < 0.75} +
+ Nilai Anda di bawah ambang batas 75%. Pelajari kembali topik di bawah ini. +
+ {:else if total != null && total > 0} +
+ Selamat! Anda telah memahami materi ini dengan baik. +
+ {/if} {:else}

Nilai Anda: {mgr.data?.lesson_progress_status}

{/if} diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 9fdd687..821872f 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -249,13 +249,32 @@ export class LessonManager { async completeLesson(status = 'completed') { if (this.lessonCompleted && status === 'completed') return; - this.showCelebration = true; if (get(authLoggedIn)) { const lessonName = this.slug.replace('.md', ''); - await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' }); + const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' }); + const isDone = res && typeof res.is_done === 'boolean' ? res.is_done : true; + this.lessonCompleted = isDone; + if (isDone) { + this.showCelebration = true; + } + lessonContext.update(ctx => ctx ? { ...ctx, completed: isDone } : ctx); + if (this.data?.ordered_lessons) { + const current = this.data.ordered_lessons.find( + (l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '') + ); + if (current) current.completed = isDone; + } + } else { + this.showCelebration = true; this.lessonCompleted = true; lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx); + if (this.data?.ordered_lessons) { + const current = this.data.ordered_lessons.find( + (l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '') + ); + if (current) current.completed = true; + } } } @@ -384,10 +403,30 @@ export class LessonManager { const payload = this.buildQuizAttemptPayload(reason); if (!payload) return; - // Update status UI lokal (tanpa menulis progress ulang — endpoint - // attempt atomic yang menulis progress). Celebration hanya untuk - // penyelesaian normal, bukan exit penalti/termination. - this.markQuizCompletedLocally(reason === 'completed'); + // Hitung perkiraan awal (optimistic) lulus/tidaknya kuis (ambang batas 75%) + const isQuizScorePassed = + reason === 'completed' && this.quizResult + ? this.quizResult.totalMcq > 0 + ? this.quizResult.correctMcq / this.quizResult.totalMcq >= 0.75 + : true + : false; + + const hasExerciseTab = + this.data?.active_tabs?.some((t) => + ['c', 'python', 'circuit', 'flowchart', 'velxio'].includes(t) + ) ?? false; + + // Jika lesson juga memiliki latihan dan latihan belum selesai, kuis saja belum menyelesaikan lesson + const exerciseDone = + this.data?.lesson_progress_status?.startsWith('done:1:') || + this.cPassed || + this.pythonPassed || + this.circuitPassed || + this.flowchartPassed; + const optimisticLessonDone = isQuizScorePassed && (!hasExerciseTab || exerciseDone); + + // Update status UI lokal secara optimis + this.markQuizCompletedLocally(optimisticLessonDone); if (reason === 'focus_lost' || reason === 'page_unload') { // Jangan mengandalkan await dari event lifecycle. Beacon pertama; @@ -395,21 +434,31 @@ export class LessonManager { const ok = lessonProgressBeacon(payload); if (!ok && typeof document !== 'undefined' && document.visibilityState === 'visible') { try { - await submitLessonProgress(payload); + const res = await submitLessonProgress(payload); + if (this.data) this.data.has_quiz_attempt = true; + if (res && res.success && typeof res.is_done === 'boolean') { + this.markQuizCompletedLocally(res.is_done); + } } catch { /* best-effort saja — jangan memblokir UI */ } } return; - } + } - try { - await submitLessonProgress(payload); + try { + const res = await submitLessonProgress(payload); + if (this.data) this.data.has_quiz_attempt = true; + if (res && res.success && typeof res.is_done === 'boolean') { + this.markQuizCompletedLocally(res.is_done); + } } catch { // Fallback legacy: skor tetap tersimpan walau endpoint attempt // tidak bisa dijangkau. Tidak membuat attempt kedua — endpoint // attempt atomic; jalur ini hanya penyelamat last-resort. - await this.completeLesson(this.quizResult.statusString); + if (isQuizScorePassed) { + await this.completeLesson(this.quizResult.statusString); + } } } @@ -418,10 +467,16 @@ export class LessonManager { * memanggil endpoint terpisah — attempt atomic /api/lesson-progress * yang menulis progress. */ - private markQuizCompletedLocally(celebrate: boolean) { - this.lessonCompleted = true; - if (celebrate) this.showCelebration = true; - lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx)); + private markQuizCompletedLocally(passed: boolean) { + this.lessonCompleted = passed; + this.showCelebration = passed; + lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx)); + if (this.data?.ordered_lessons) { + const current = this.data.ordered_lessons.find( + (l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '') + ); + if (current) current.completed = passed; + } } /** diff --git a/routes/lesson_progress.py b/routes/lesson_progress.py index 61252f4..c776e96 100644 --- a/routes/lesson_progress.py +++ b/routes/lesson_progress.py @@ -84,9 +84,10 @@ def submit_lesson_progress(): has_exercise, has_quiz = get_lesson_components(lesson_name) + progress_rec = None if progress_type == "exercise": repo.set_exercise_passed(db, user_id=user.id, lesson_id=lesson.id) - repo.recompute_progress( + progress_rec = repo.recompute_progress( db, user_id=user.id, lesson_id=lesson.id, @@ -97,7 +98,19 @@ def submit_lesson_progress(): done_min_percent=LESSON_DONE_MIN_PERCENT, ) db.commit() - return jsonify({"success": True, "idempotent": False}) + return jsonify( + { + "success": True, + "idempotent": False, + "is_done": progress_rec.state == "done" if progress_rec else False, + "state": progress_rec.state if progress_rec else "not_started", + "composite_percent": ( + round(progress_rec.composite_percent, 2) + if progress_rec and progress_rec.composite_percent is not None + else None + ), + } + ) # ── progress_type == 'quiz' ── mirror submit_quiz_attempt ───── attempt_id = str(data.get("attempt_id") or "").strip() @@ -199,7 +212,7 @@ def submit_lesson_progress(): quiz_earned=earned, quiz_total=total, ) - repo.recompute_progress( + progress_rec = repo.recompute_progress( db, user_id=user.id, lesson_id=lesson.id, @@ -209,6 +222,8 @@ def submit_lesson_progress(): quiz_weight=LESSON_QUIZ_WEIGHT, done_min_percent=LESSON_DONE_MIN_PERCENT, ) + else: + progress_rec = repo.get_progress(db, user_id=user.id, lesson_id=lesson.id) db.commit() except IntegrityError: db.rollback() @@ -231,6 +246,13 @@ def submit_lesson_progress(): "success": True, "idempotent": not created, "attempt_id": attempt.id, + "is_done": progress_rec.state == "done" if progress_rec else False, + "state": progress_rec.state if progress_rec else "not_started", + "composite_percent": ( + round(progress_rec.composite_percent, 2) + if progress_rec and progress_rec.composite_percent is not None + else None + ), } ) diff --git a/routes/lessons.py b/routes/lessons.py index f8dbd93..b098fe3 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -19,7 +19,7 @@ from services.lesson_service import ( render_home_content, find_lesson_file, ) -from services.token_service import get_student_progress +from services.token_service import get_student_progress, has_quiz_attempt lessons_bp = Blueprint('lessons', __name__) @@ -142,12 +142,15 @@ def api_lesson(filename): progress = None lesson_completed = False lesson_progress_status = '' + lesson_slug = full_filename.replace('.md', '') + user_has_quiz_attempt = False if token: progress = get_student_progress(token) if progress: - status = progress.get(full_filename.replace('.md', ''), '') + status = progress.get(lesson_slug, '') lesson_progress_status = status lesson_completed = status not in (None, '', 'not_started') + user_has_quiz_attempt = has_quiz_attempt(token, lesson_slug) # Detect sub-home for this lesson's folder (untuk sidebar & navigasi) sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path) @@ -262,6 +265,7 @@ def api_lesson(filename): 'lesson_progress_status': lesson_progress_status, 'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(), 'lesson_completed': lesson_completed, + 'has_quiz_attempt': user_has_quiz_attempt, 'locked': is_locked, 'missing_prerequisites': missing_prereqs, 'prev_lesson': prev_lesson, diff --git a/services/storage/postgres_backend.py b/services/storage/postgres_backend.py index 675447a..3fa22e6 100644 --- a/services/storage/postgres_backend.py +++ b/services/storage/postgres_backend.py @@ -99,6 +99,26 @@ def get_student_progress(token): db.close() +def has_quiz_attempt(token, lesson_name): + """Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson ini.""" + if not token or SessionLocal is None: + return False + db = SessionLocal() + try: + user = find_user_by_raw_token(db, token) + if user is None: + return False + lesson = get_lesson_by_slug(db, lesson_name) + if lesson is None: + return False + attempt = repo.get_quiz_attempt_for_user_lesson( + db, user_id=user.id, lesson_id=lesson.id + ) + return attempt is not None + finally: + db.close() + + def reset_progress(student_id, lesson_name): """Reset via student_id = user.id (PG) — teacher tidak perlu token siswa. diff --git a/services/tests/test_lesson_progress_routes.py b/services/tests/test_lesson_progress_routes.py index 16ab0a3..6fc535f 100644 --- a/services/tests/test_lesson_progress_routes.py +++ b/services/tests/test_lesson_progress_routes.py @@ -129,6 +129,25 @@ def test_quiz_submit_creates_attempt(client): assert body["success"] is True assert body["idempotent"] is False assert "attempt_id" in body + assert body["is_done"] is True + assert body["state"] == "done" + assert body["composite_percent"] == 100.0 + + +def test_quiz_submit_below_threshold_marks_in_progress(client, monkeypatch): + import routes.lesson_progress as lp + + monkeypatch.setattr(lp, "get_lesson_components", lambda name: (False, True)) + resp = client.post( + "/lesson-progress", + json=_payload({"status": "submitted", "termination_reason": None, "score": "1/4"}), + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body["success"] is True + assert body["is_done"] is False + assert body["state"] == "in_progress" + assert body["composite_percent"] == 25.0 def test_quiz_retry_same_attempt_id_idempotent(client): diff --git a/services/tests/test_sub_home_api.py b/services/tests/test_sub_home_api.py index b699111..08d2042 100644 --- a/services/tests/test_sub_home_api.py +++ b/services/tests/test_sub_home_api.py @@ -303,3 +303,13 @@ def test_lesson_detail_missing_prerequisites_with_score(client, tmp_path, monkey data = resp.get_json() assert data["locked"] is True assert data["missing_prerequisites"] == ["dasar"] + assert "has_quiz_attempt" in data + + +def test_lesson_detail_has_quiz_attempt_field(client, tmp_path, monkeypatch): + _make_bab_with_prereq(tmp_path, monkeypatch, "- [Dasar](lesson/dasar.md)") + from routes import lessons as lessons_routes + monkeypatch.setattr(lessons_routes, "has_quiz_attempt", lambda token, slug: True) + resp = client.get("/lesson/lanjutan.json?token=siswa1") + assert resp.status_code == 200 + assert resp.get_json()["has_quiz_attempt"] is True diff --git a/services/token_service.py b/services/token_service.py index 9a030b4..c9053d5 100644 --- a/services/token_service.py +++ b/services/token_service.py @@ -27,6 +27,11 @@ def get_student_progress(token): return _backend().get_student_progress(token) +def has_quiz_attempt(token, lesson_name): + """Cek apakah siswa sudah memiliki rekaman attempt kuis untuk lesson.""" + return _backend().has_quiz_attempt(token, lesson_name) + + def reset_student_progress(student_id, lesson_name): """Reset progress siswa by student_id (id anonim dari report — bukan token).""" return _backend().reset_progress(student_id, lesson_name)