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:
parent
53b704c7de
commit
ba1e7240f3
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') ?? ''
|
||||
|
|
|
|||
|
|
@ -200,6 +200,10 @@ import QuizQuestionView from './QuizQuestionView.svelte';
|
|||
mgr.velxioBridge.destroy();
|
||||
mgr.velxioBridge = null;
|
||||
}
|
||||
|
||||
if (nav.type !== 'leave') {
|
||||
mgr.reset();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
|
|
|
|||
|
|
@ -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') ?? ''
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
Loading…
Reference in New Issue