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
This commit is contained in:
a2nr 2026-09-16 22:19:53 +07:00
parent 53b704c7de
commit ba1e7240f3
7 changed files with 324 additions and 36 deletions

View File

@ -19,3 +19,20 @@ declare module '*?url' {
export default url;
}
declare namespace svelteHTML {
interface IframeHTMLAttributes {
credentialless?: boolean | string;
}
interface HTMLAttributes<T> {
credentialless?: boolean | string;
}
}
declare module 'svelte/elements' {
export interface HTMLAttributes<T> {
credentialless?: boolean | string;
}
}

View File

@ -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<string, any>;
let mockOutbox: Array<any>;
let mockQuizAttempts: Array<any>;
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);
});
});

View File

@ -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<void> {
async recordProgress(lessonSlug: string, completed: boolean, payload?: any): Promise<void> {
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<MyProgress | null> {
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<Record<string, boolean>> {
if (!db) return {};
const all = await db.myProgress.toArray();
const map: Record<string, boolean> = {};
for (const item of all) {
map[item.lessonSlug] = item.completed;
}
return map;
}
/**
* Record a completed quiz attempt locally, then sync.
*/
async recordQuizAttempt(attempt: Omit<MyQuizAttempt, 'id' | 'synced'>, syncPayload: any): Promise<void> {
async recordQuizAttempt(attempt: Omit<MyQuizAttempt, 'id' | 'synced'>, syncPayload?: any): Promise<void> {
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 });
}

View File

@ -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') ?? ''

View File

@ -200,6 +200,10 @@ import QuizQuestionView from './QuizQuestionView.svelte';
mgr.velxioBridge.destroy();
mgr.velxioBridge = null;
}
if (nav.type !== 'leave') {
mgr.reset();
}
});
onMount(() => {

View File

@ -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') ?? ''

View File

@ -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', '')