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;
|
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,
|
* 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;
|
if (!db) return;
|
||||||
|
|
||||||
const progress: MyProgress = {
|
const progress: MyProgress = {
|
||||||
|
|
@ -45,13 +45,37 @@ export class SyncManager {
|
||||||
};
|
};
|
||||||
await db.myProgress.put(progress);
|
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.
|
* 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;
|
if (!db) return;
|
||||||
|
|
||||||
await db.myQuizAttempts.add({
|
await db.myQuizAttempts.add({
|
||||||
|
|
@ -59,7 +83,9 @@ export class SyncManager {
|
||||||
synced: false
|
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) {
|
if (entry.id) {
|
||||||
await db.outbox.delete(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) {
|
} else if (entry.retries < 5 && entry.id) {
|
||||||
await db.outbox.update(entry.id, { retries: entry.retries + 1 });
|
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 type { Lesson } from '$types/lesson';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, fetch }) => {
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
const token = typeof window !== 'undefined'
|
const token = typeof window !== 'undefined'
|
||||||
? localStorage.getItem('student_token') ?? ''
|
? localStorage.getItem('student_token') ?? ''
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,10 @@ import QuizQuestionView from './QuizQuestionView.svelte';
|
||||||
mgr.velxioBridge.destroy();
|
mgr.velxioBridge.destroy();
|
||||||
mgr.velxioBridge = null;
|
mgr.velxioBridge = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nav.type !== 'leave') {
|
||||||
|
mgr.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { getLesson } from '$services/api';
|
import { getLesson } from '$services/api';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params, fetch }) => {
|
export const load: PageLoad = async ({ params, fetch }) => {
|
||||||
const token = typeof window !== 'undefined'
|
const token = typeof window !== 'undefined'
|
||||||
? localStorage.getItem('student_token') ?? ''
|
? 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) {
|
init(lesson: LessonContent) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
this.reset();
|
||||||
|
|
||||||
this.data = lesson;
|
this.data = lesson;
|
||||||
this.lessonCompleted = lesson.lesson_completed;
|
this.lessonCompleted = Boolean(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.cCode = lesson.initial_code_c || '';
|
this.cCode = lesson.initial_code_c || '';
|
||||||
this.pythonCode = lesson.initial_python || '';
|
this.pythonCode = lesson.initial_python || '';
|
||||||
|
|
@ -220,40 +260,61 @@ export class LessonManager {
|
||||||
this.currentLanguage = initLang;
|
this.currentLanguage = initLang;
|
||||||
this.currentCode = initLang === 'python' ? this.pythonCode : (this.cCode || lesson.initial_code || '');
|
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.activeTab = pickDefaultTab(lesson);
|
||||||
|
|
||||||
this.deployState = 'idle';
|
|
||||||
|
|
||||||
this.mobileMode = 'hidden';
|
|
||||||
|
|
||||||
lessonContext.set({
|
lessonContext.set({
|
||||||
title: lesson.lesson_title,
|
title: lesson.lesson_title,
|
||||||
completed: lesson.lesson_completed,
|
completed: this.lessonCompleted,
|
||||||
prevLesson: lesson.prev_lesson,
|
prevLesson: lesson.prev_lesson,
|
||||||
nextLesson: lesson.next_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') {
|
async completeLesson(status = 'completed') {
|
||||||
if (this.lessonCompleted && status === 'completed') return;
|
if (this.lessonCompleted && status === 'completed') return;
|
||||||
|
|
||||||
|
const lessonName = this.slug.replace('.md', '');
|
||||||
|
|
||||||
if (get(authLoggedIn)) {
|
if (get(authLoggedIn)) {
|
||||||
const lessonName = this.slug.replace('.md', '');
|
|
||||||
let isDone = true;
|
let isDone = true;
|
||||||
try {
|
try {
|
||||||
const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
|
const res = await submitLessonProgress({ token: auth.token, lesson_name: lessonName, type: 'exercise' });
|
||||||
|
|
@ -277,6 +338,7 @@ export class LessonManager {
|
||||||
} else {
|
} else {
|
||||||
this.showCelebration = true;
|
this.showCelebration = true;
|
||||||
this.lessonCompleted = true;
|
this.lessonCompleted = true;
|
||||||
|
await syncManager.recordProgress(lessonName, true, null);
|
||||||
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
||||||
if (this.data?.ordered_lessons) {
|
if (this.data?.ordered_lessons) {
|
||||||
const current = this.data.ordered_lessons.find(
|
const current = this.data.ordered_lessons.find(
|
||||||
|
|
@ -480,6 +542,15 @@ export class LessonManager {
|
||||||
this.lessonCompleted = passed;
|
this.lessonCompleted = passed;
|
||||||
this.showCelebration = passed;
|
this.showCelebration = passed;
|
||||||
lessonContext.update((ctx) => (ctx ? { ...ctx, completed: passed } : ctx));
|
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) {
|
if (this.data?.ordered_lessons) {
|
||||||
const current = this.data.ordered_lessons.find(
|
const current = this.data.ordered_lessons.find(
|
||||||
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
|
(l) => l.filename === this.slug || l.filename.replace('.md', '') === this.slug.replace('.md', '')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue