From ba1e7240f3244ec0fae741bebd85ffc3c0a3950e Mon Sep 17 00:00:00 2001 From: a2nr Date: Wed, 16 Sep 2026 22:19:53 +0700 Subject: [PATCH] feat(offline-db): implement offline progress persistence and sync manager for lessons - Add getProgress and getAllProgress to local IndexedDB SyncManager - Support opportunistic background sync for lesson progress and quizzes - Connect LessonManager to IndexedDB progress cache for offline lesson completion status - Disable SSR for /lesson and /bab routes to ensure consistent client-side local storage --- .../services/code-runner/declarations.d.ts | 17 ++ .../services/offline-db/sync-manager.test.ts | 163 ++++++++++++++++++ .../lib/services/offline-db/sync-manager.ts | 39 ++++- frontend/src/routes/bab/[folder]/+page.ts | 2 + .../src/routes/lesson/[slug]/+page.svelte | 4 + frontend/src/routes/lesson/[slug]/+page.ts | 2 + .../src/routes/lesson/[slug]/lesson.svelte.ts | 133 ++++++++++---- 7 files changed, 324 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/services/offline-db/sync-manager.test.ts diff --git a/frontend/src/lib/services/code-runner/declarations.d.ts b/frontend/src/lib/services/code-runner/declarations.d.ts index c902bbf..2446e53 100644 --- a/frontend/src/lib/services/code-runner/declarations.d.ts +++ b/frontend/src/lib/services/code-runner/declarations.d.ts @@ -19,3 +19,20 @@ declare module '*?url' { export default url; } +declare namespace svelteHTML { + interface IframeHTMLAttributes { + credentialless?: boolean | string; + } + interface HTMLAttributes { + credentialless?: boolean | string; + } +} + +declare module 'svelte/elements' { + export interface HTMLAttributes { + credentialless?: boolean | string; + } +} + + + diff --git a/frontend/src/lib/services/offline-db/sync-manager.test.ts b/frontend/src/lib/services/offline-db/sync-manager.test.ts new file mode 100644 index 0000000..c6eef50 --- /dev/null +++ b/frontend/src/lib/services/offline-db/sync-manager.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SyncManager } from './sync-manager'; +import * as schemaModule from './schema'; + +describe('SyncManager', () => { + let syncManager: SyncManager; + let mockMyProgress: Map; + let mockOutbox: Array; + let mockQuizAttempts: Array; + + beforeEach(() => { + mockMyProgress = new Map(); + mockOutbox = []; + mockQuizAttempts = []; + + const mockDb = { + myProgress: { + put: vi.fn(async (item) => { + mockMyProgress.set(item.lessonSlug, item); + return item.lessonSlug; + }), + get: vi.fn(async (slug) => { + return mockMyProgress.get(slug) || undefined; + }), + toArray: vi.fn(async () => { + return Array.from(mockMyProgress.values()); + }), + update: vi.fn(async (slug, updates) => { + const existing = mockMyProgress.get(slug); + if (existing) { + mockMyProgress.set(slug, { ...existing, ...updates }); + } + }) + }, + outbox: { + add: vi.fn(async (entry) => { + const id = mockOutbox.length + 1; + const item = { ...entry, id }; + mockOutbox.push(item); + return id; + }), + orderBy: vi.fn(() => ({ + toArray: vi.fn(async () => [...mockOutbox]) + })), + delete: vi.fn(async (id) => { + const idx = mockOutbox.findIndex((e) => e.id === id); + if (idx >= 0) mockOutbox.splice(idx, 1); + }), + update: vi.fn(async (id, updates) => { + const item = mockOutbox.find((e) => e.id === id); + if (item) Object.assign(item, updates); + }) + }, + myQuizAttempts: { + add: vi.fn(async (item) => { + mockQuizAttempts.push(item); + return mockQuizAttempts.length; + }) + }, + myCodeDrafts: { + where: vi.fn(() => ({ + first: vi.fn(async () => null) + })), + add: vi.fn() + } + }; + + vi.spyOn(schemaModule, 'db', 'get').mockReturnValue(mockDb as any); + + syncManager = new SyncManager(); + }); + + it('records progress locally and enqueues sync to /lesson-progress when authenticated', async () => { + await syncManager.recordProgress('variabel', true, { + token: 'student-tok-1', + lesson_name: 'variabel', + type: 'exercise' + }); + + const rec = await syncManager.getProgress('variabel'); + expect(rec).not.toBeNull(); + expect(rec?.lessonSlug).toBe('variabel'); + expect(rec?.completed).toBe(true); + expect(rec?.synced).toBe(false); + + expect(mockOutbox.length).toBe(1); + expect(mockOutbox[0].endpoint).toBe('/lesson-progress'); + expect(mockOutbox[0].payload.token).toBe('student-tok-1'); + }); + + it('records progress locally without enqueueing outbox in guest mode', async () => { + await syncManager.recordProgress('percabangan', true, null); + + const rec = await syncManager.getProgress('percabangan'); + expect(rec).not.toBeNull(); + expect(rec?.lessonSlug).toBe('percabangan'); + expect(rec?.completed).toBe(true); + + expect(mockOutbox.length).toBe(0); + }); + + it('returns all completed lessons map with getAllProgress', async () => { + await syncManager.recordProgress('lesson_a', true); + await syncManager.recordProgress('lesson_b', false); + await syncManager.recordProgress('lesson_c', true); + + const map = await syncManager.getAllProgress(); + expect(map).toEqual({ + lesson_a: true, + lesson_b: false, + lesson_c: true + }); + }); + + it('records quiz attempt and enqueues sync to /lesson-progress', async () => { + await syncManager.recordQuizAttempt( + { + lessonSlug: 'quiz_test', + attemptId: 'attempt-uuid-1', + answers: {}, + score: 100, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString() + }, + { token: 'student-tok-1', lesson_name: 'quiz_test', type: 'quiz' } + ); + + expect(mockQuizAttempts.length).toBe(1); + expect(mockOutbox.length).toBe(1); + expect(mockOutbox[0].endpoint).toBe('/lesson-progress'); + expect(mockOutbox[0].payload.lesson_name).toBe('quiz_test'); + }); + + it('drains outbox queue to /api/lesson-progress and marks progress synced', async () => { + await syncManager.recordProgress('loop_test', true, { + token: 'tok-1', + lesson_name: 'loop_test', + type: 'exercise' + }); + expect(mockOutbox.length).toBe(1); + + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ success: true, is_done: true }) + })); + (global as any).fetch = fetchMock; + Object.defineProperty(globalThis.navigator, 'onLine', { value: true, configurable: true }); + + await syncManager.drainQueue(); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/lesson-progress', + expect.objectContaining({ + method: 'POST' + }) + ); + expect(mockOutbox.length).toBe(0); + + const progress = await syncManager.getProgress('loop_test'); + expect(progress?.synced).toBe(true); + }); +}); diff --git a/frontend/src/lib/services/offline-db/sync-manager.ts b/frontend/src/lib/services/offline-db/sync-manager.ts index b1ebab1..f56333e 100644 --- a/frontend/src/lib/services/offline-db/sync-manager.ts +++ b/frontend/src/lib/services/offline-db/sync-manager.ts @@ -32,9 +32,9 @@ export class SyncManager { /** * Save progress in local IndexedDB mirror immediately, - * then opportunistically sync to server. + * then opportunistically sync to server if authenticated payload is provided. */ - async recordProgress(lessonSlug: string, completed: boolean, payload: any): Promise { + async recordProgress(lessonSlug: string, completed: boolean, payload?: any): Promise { if (!db) return; const progress: MyProgress = { @@ -45,13 +45,37 @@ export class SyncManager { }; await db.myProgress.put(progress); - await this.enqueueAndTrySync('/progress', payload); + if (payload && payload.token) { + await this.enqueueAndTrySync('/lesson-progress', payload); + } + } + + /** + * Get progress for a specific lesson from local IndexedDB mirror. + */ + async getProgress(lessonSlug: string): Promise { + if (!db) return null; + const record = await db.myProgress.get(lessonSlug); + return record ?? null; + } + + /** + * Get all recorded completed lesson slugs from local IndexedDB mirror. + */ + async getAllProgress(): Promise> { + if (!db) return {}; + const all = await db.myProgress.toArray(); + const map: Record = {}; + for (const item of all) { + map[item.lessonSlug] = item.completed; + } + return map; } /** * Record a completed quiz attempt locally, then sync. */ - async recordQuizAttempt(attempt: Omit, syncPayload: any): Promise { + async recordQuizAttempt(attempt: Omit, syncPayload?: any): Promise { if (!db) return; await db.myQuizAttempts.add({ @@ -59,7 +83,9 @@ export class SyncManager { synced: false }); - await this.enqueueAndTrySync('/progress', syncPayload); + if (syncPayload && syncPayload.token) { + await this.enqueueAndTrySync('/lesson-progress', syncPayload); + } } /** @@ -135,6 +161,9 @@ export class SyncManager { if (entry.id) { await db.outbox.delete(entry.id); } + if (entry.payload?.lesson_name) { + await db.myProgress.update(entry.payload.lesson_name, { synced: true }).catch(() => {}); + } } else if (entry.retries < 5 && entry.id) { await db.outbox.update(entry.id, { retries: entry.retries + 1 }); } diff --git a/frontend/src/routes/bab/[folder]/+page.ts b/frontend/src/routes/bab/[folder]/+page.ts index 4c16fb4..32178c6 100644 --- a/frontend/src/routes/bab/[folder]/+page.ts +++ b/frontend/src/routes/bab/[folder]/+page.ts @@ -2,6 +2,8 @@ import type { PageLoad } from './$types'; import type { Lesson } from '$types/lesson'; import { error } from '@sveltejs/kit'; +export const ssr = false; + export const load: PageLoad = async ({ params, fetch }) => { const token = typeof window !== 'undefined' ? localStorage.getItem('student_token') ?? '' diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 50177b7..9eb5139 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -200,6 +200,10 @@ import QuizQuestionView from './QuizQuestionView.svelte'; mgr.velxioBridge.destroy(); mgr.velxioBridge = null; } + + if (nav.type !== 'leave') { + mgr.reset(); + } }); onMount(() => { diff --git a/frontend/src/routes/lesson/[slug]/+page.ts b/frontend/src/routes/lesson/[slug]/+page.ts index f7d25ee..c38f690 100644 --- a/frontend/src/routes/lesson/[slug]/+page.ts +++ b/frontend/src/routes/lesson/[slug]/+page.ts @@ -1,6 +1,8 @@ import { getLesson } from '$services/api'; import type { PageLoad } from './$types'; +export const ssr = false; + export const load: PageLoad = async ({ params, fetch }) => { const token = typeof window !== 'undefined' ? localStorage.getItem('student_token') ?? '' diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index a3079e9..f4ded66 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -196,20 +196,60 @@ export class LessonManager { }); } + reset() { + this.data = null; + this.lessonCompleted = false; + this.isQuizMode = false; + this.quizSession = null; + this.quizCurrentIndex = 0; + this.quizCurrentQuestion = null; + this.quizResult = null; + this.quizAttemptId = null; + this.quizStartedAt = null; + this.quizFinalized = false; + this.quizTerminationReason = null; + this.visibilityEventCount = 0; + + this.currentCode = ''; + this.currentLanguage = 'c'; + this.cCode = ''; + this.pythonCode = ''; + + this.cOut = this.freshOutput(); + this.pyOut = this.freshOutput(); + this.circuitOut = this.freshOutput(); + this.velxioOut = this.freshOutput(); + this.flowchartOut = this.freshOutput(); + + this.cPassed = false; + this.pythonPassed = false; + this.circuitPassed = false; + this.flowchartPassed = false; + + this.showSolution = false; + this.showCelebration = false; + this.velxioSaving = false; + this.velxioReady = false; + this.velxioError = false; + + if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; } + if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; } + + this.deployState = 'idle'; + this.mobileMode = 'hidden'; + + this.editor = null; + this.circuitEditor = null; + this.flowchartTab = null; + this.velxioIframe = null; + } + init(lesson: LessonContent) { untrack(() => { + this.reset(); + this.data = lesson; - this.lessonCompleted = lesson.lesson_completed; - this.isQuizMode = false; - this.quizSession = null; - this.quizCurrentIndex = 0; - this.quizCurrentQuestion = null; - this.quizResult = null; - this.quizAttemptId = null; - this.quizStartedAt = null; - this.quizFinalized = false; - this.quizTerminationReason = null; - this.visibilityEventCount = 0; + this.lessonCompleted = Boolean(lesson.lesson_completed); this.cCode = lesson.initial_code_c || ''; this.pythonCode = lesson.initial_python || ''; @@ -220,40 +260,61 @@ export class LessonManager { this.currentLanguage = initLang; this.currentCode = initLang === 'python' ? this.pythonCode : (this.cCode || lesson.initial_code || ''); - this.cOut = this.freshOutput(); - this.pyOut = this.freshOutput(); - this.circuitOut = this.freshOutput(); - this.velxioOut = this.freshOutput(); - this.cPassed = false; - this.pythonPassed = false; - this.circuitPassed = false; - this.showSolution = false; - - if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; } - if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; } - this.velxioReady = false; - this.velxioError = false; - this.activeTab = pickDefaultTab(lesson); - this.deployState = 'idle'; - - this.mobileMode = 'hidden'; - lessonContext.set({ title: lesson.lesson_title, - completed: lesson.lesson_completed, + completed: this.lessonCompleted, prevLesson: lesson.prev_lesson, nextLesson: lesson.next_lesson }); + + if (typeof window !== 'undefined') { + const lessonSlug = this.slug.replace('.md', ''); + syncManager.getProgress(lessonSlug).then((localProg) => { + if (localProg && localProg.completed && !this.lessonCompleted) { + 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; + } + } + }).catch(() => {}); + + syncManager.getAllProgress().then((allProg) => { + if (this.data?.ordered_lessons) { + let updated = false; + for (const item of this.data.ordered_lessons) { + const s = item.filename.replace('.md', ''); + if (allProg[s] && !item.completed) { + item.completed = true; + updated = true; + } + } + if (updated && this.slug) { + const curr = this.data.ordered_lessons.find( + (l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '') + ); + if (curr?.completed && !this.lessonCompleted) { + this.lessonCompleted = true; + lessonContext.update((ctx) => (ctx ? { ...ctx, completed: true } : ctx)); + } + } + } + }).catch(() => {}); + } }); } async completeLesson(status = 'completed') { if (this.lessonCompleted && status === 'completed') return; + const lessonName = this.slug.replace('.md', ''); + if (get(authLoggedIn)) { - const lessonName = this.slug.replace('.md', ''); let isDone = true; try { const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' }); @@ -277,6 +338,7 @@ export class LessonManager { } else { this.showCelebration = true; this.lessonCompleted = true; + await syncManager.recordProgress(lessonName, true, null); lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx); if (this.data?.ordered_lessons) { const current = this.data.ordered_lessons.find( @@ -480,6 +542,15 @@ export class LessonManager { this.lessonCompleted = passed; this.showCelebration = passed; lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx)); + const lessonName = this.slug.replace('.md', ''); + if (passed) { + const token = get(authToken) || (typeof window !== 'undefined' ? localStorage.getItem('student_token') : '') || ''; + syncManager.recordProgress( + lessonName, + true, + token ? { token, lesson_name: lessonName, type: 'quiz' } : null + ).catch(() => {}); + } 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', '')