feat: custom evaluation config

This commit is contained in:
a2nr 2026-06-17 20:34:36 +07:00
parent 120a1338c0
commit e4ec3f1ded
14 changed files with 305 additions and 44 deletions

View File

@ -198,6 +198,7 @@ Untuk materi yang menggunakan simulator Arduino (Velxio):
| `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) |
| `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) |
| `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa |
| `---EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms` dalam milidetik) |
Contoh materi Arduino:
@ -246,6 +247,12 @@ LED OFF
pinMode
digitalWrite
---END_KEY_TEXT---
---EVALUATION_CONFIG---
{
"timeout_ms": 8000
}
---END_EVALUATION_CONFIG---
```
##### Referensi Nama Pin Komponen Velxio

View File

@ -312,6 +312,7 @@ Elemes mendukung beberapa mode lesson melalui **marker** di file markdown. Mode
| `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) |
| `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) |
| `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit |
| `---EVALUATION_CONFIG---` / `---END_EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms`) |
### Fitur Tombol "Coba" (Code Try-out)

View File

@ -14,5 +14,14 @@
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => console.log('[SW] Registered:', reg.scope))
.catch(err => console.warn('[SW] Registration failed:', err));
});
}
</script>
</body>
</html>

View File

@ -15,18 +15,21 @@
let ready = $state(false);
let simApi = $state<CircuitJSApi | null>(null);
let saving = $state(false);
let saveTimeout: ReturnType<typeof setTimeout>;
let autoSaveInterval: ReturnType<typeof setInterval>;
let saveTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
let isDestroyed = false;
let lastLoadedCircuit = $state('');
let lastStorageKey = $state<string | undefined>(undefined);
function saveToStorage(text: string) {
if (!storageKey) return;
if (!storageKey || isDestroyed) return;
saving = true;
clearTimeout(saveTimeout);
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
localStorage.setItem(storageKey, text);
if (!isDestroyed && storageKey) {
localStorage.setItem(storageKey, text);
}
saving = false;
saveTimeout = null;
}, 1000);
}
@ -137,8 +140,11 @@
// Cleanup timers on destroy
$effect(() => {
return () => {
clearInterval(autoSaveInterval);
clearTimeout(saveTimeout);
isDestroyed = true;
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = null;
}
};
});

View File

@ -44,9 +44,12 @@ export function initVelxioBridge(
arduinoCircuitKey: string,
arduinoCodeKey: string,
onReady: (bridge: VelxioBridge) => void,
onSubmit: () => void
) {
onSubmit: () => void,
onCountdown?: (msRemaining: number) => void
): () => void {
let settled = false;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
const onMessage = (e: MessageEvent) => {
const type = e.data?.type;
if (!type) return;
@ -83,8 +86,30 @@ export function initVelxioBridge(
}
if (type === 'velxio:compile_result' && e.data.success) {
const timeout = data?.evaluation_config?.timeout_ms ?? 5000;
setTimeout(() => onSubmit(), timeout);
const timeout = data?.evaluation_config?.timeout_ms ?? 8000;
if (countdownInterval) clearInterval(countdownInterval);
let remaining = timeout;
if (onCountdown) {
onCountdown(remaining);
countdownInterval = setInterval(() => {
remaining -= 1000;
if (remaining <= 0) {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
} else {
onCountdown(remaining);
}
}, 1000);
}
setTimeout(() => {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
onSubmit();
}, timeout);
}
};
window.addEventListener('message', onMessage);
@ -136,10 +161,18 @@ export function initVelxioBridge(
} catch { /* cross-origin or not ready yet */ }
}, 1000);
setTimeout(() => {
clearInterval(pollReady);
if (settled) return;
settled = true;
const cleanup = () => {
window.removeEventListener('message', onMessage);
clearInterval(pollReady);
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
};
setTimeout(() => {
cleanup();
}, 30_000);
return cleanup;
}

View File

@ -101,7 +101,14 @@
beforeNavigate(() => {
lessonContext.set(null);
if (mgr.velxioBridge) { mgr.velxioBridge.destroy(); mgr.velxioBridge = null; }
if (mgr.velxioCleanup) {
mgr.velxioCleanup();
mgr.velxioCleanup = null;
}
if (mgr.velxioBridge) {
mgr.velxioBridge.destroy();
mgr.velxioBridge = null;
}
});
let contentEl = $state<HTMLElement | null>(null);

View File

@ -42,6 +42,7 @@ export class LessonManager {
velxioSaving = $state(false);
velxioError = $state(false);
velxioIframe = $state<HTMLIFrameElement | null>(null);
velxioCleanup = $state<(() => void) | null>(null);
showSolution = $state(false);
activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info');
@ -177,6 +178,7 @@ export class LessonManager {
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;
@ -411,7 +413,11 @@ export class LessonManager {
setupVelxioBridge(iframe: HTMLIFrameElement) {
this.velxioIframe = iframe;
initVelxioBridge(
if (this.velxioCleanup) {
this.velxioCleanup();
this.velxioCleanup = null;
}
this.velxioCleanup = initVelxioBridge(
iframe,
this.data,
this.arduinoCircuitKey,
@ -420,7 +426,16 @@ export class LessonManager {
this.velxioBridge = bridge;
this.velxioReady = true;
},
() => this.handleVelxioSubmit()
() => this.handleVelxioSubmit(),
(msRemaining) => {
this.activeTab = 'output';
Object.assign(this.velxioOut, {
loading: true,
output: `Kompilasi sukses. Menjalankan simulasi & merekam log serial (${Math.round(msRemaining / 1000)} detik)...`,
error: '',
success: null
});
}
);
}

BIN
frontend/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1,21 +1,32 @@
{
"name": "Elemes LMS",
"short_name": "Elemes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0d6efd",
"description": "Belajar Pemrograman C",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
"name": "Elemes LMS",
"short_name": "Elemes",
"description": "Belajar Pemrograman C & Arduino",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
"theme_color": "#0d6efd",
"categories": ["education"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Daftar Pelajaran",
"url": "/",
"description": "Lihat semua pelajaran"
}
]
}

130
frontend/static/sw.js Normal file
View File

@ -0,0 +1,130 @@
// static/sw.js
const CACHE_VERSION = 'elemes-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
// Assets yang di-precache (Vite immutable + critical)
const PRECACHE_URLS = [
'/',
'/manifest.json',
];
// Install: precache minimal assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// Activate: hapus cache lama
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys
.filter(k => k.startsWith('elemes-') && k !== STATIC_CACHE && k !== API_CACHE && k !== ASSET_CACHE)
.map(k => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Hanya handle GET
if (request.method !== 'GET') return;
// NetworkOnly: API yang tidak boleh di-cache
if (url.pathname.startsWith('/api/compile') ||
url.pathname.startsWith('/api/track-progress') ||
url.pathname.startsWith('/api/login') ||
url.pathname.startsWith('/api/logout')) {
return; // biarkan browser handle
}
// CacheFirst: Static assets (Vite immutable dengan hash)
if (url.pathname.startsWith('/_app/immutable/')) {
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
return;
}
// CacheFirst: Gambar lesson (assets)
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE, 7));
return;
}
// CacheFirst: Font files (immutable)
if (url.hostname === 'fonts.gstatic.com') {
event.respondWith(cacheFirst(request, STATIC_CACHE, 365));
return;
}
// NetworkFirst: API lesson data (cache 6 jam)
if (url.pathname.startsWith('/api/lesson/') && url.pathname.endsWith('.json')) {
event.respondWith(networkFirst(request, API_CACHE, 6 * 60));
return;
}
// NetworkFirst: API lesson list (cache 1 jam)
if (url.pathname.startsWith('/api/lessons')) {
event.respondWith(networkFirst(request, API_CACHE, 60));
return;
}
// StaleWhileRevalidate: HTML pages
if (request.headers.get('accept')?.includes('text/html')) {
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
return;
}
// StaleWhileRevalidate: Google Fonts CSS, KaTeX
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'cdn.jsdelivr.net') {
event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
return;
}
});
// ── Helpers ──────────────────────────────────────────────────────
async function cacheFirst(request, cacheName, maxAgeDays = 7) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}
async function networkFirst(request, cacheName, maxAgeMinutes = 60) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline', cached: false }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkFetch = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => null);
return cached || (await networkFetch) ||
new Response('Offline — halaman ini belum pernah dibuka sebelumnya.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}

View File

@ -6,20 +6,42 @@ import os
import re
import html as html_module
from functools import lru_cache
from threading import Lock
import markdown as md
from config import CONTENT_DIR
_home_cache = {'content': None, 'mtime': -1.0}
_home_lock = Lock()
_markdown_cache = {}
_markdown_lock = Lock()
@lru_cache(maxsize=1)
def _read_home_md():
"""Read home.md and return its content, or empty string if missing."""
path = os.path.join(CONTENT_DIR, "home.md")
if not os.path.exists(path):
return ""
with open(path, 'r', encoding='utf-8') as f:
return f.read()
try:
current_mtime = os.path.getmtime(path)
except OSError:
return _home_cache.get('content') or ""
with _home_lock:
if current_mtime != _home_cache['mtime']:
with open(path, 'r', encoding='utf-8') as f:
_home_cache['content'] = f.read()
_home_cache['mtime'] = current_mtime
# Invalidate all downstream caches
find_lesson_file.cache_clear()
get_lessons.cache_clear()
get_lesson_names.cache_clear()
get_lessons_with_learning_objectives.cache_clear()
with _markdown_lock:
_markdown_cache.clear()
return _home_cache['content']
def _parse_lesson_links(home_content):
@ -417,9 +439,18 @@ def _extract_section(content, start_marker, end_marker):
return extracted, remaining
@lru_cache(maxsize=32)
def render_markdown_content(file_path):
"""Parse a lesson markdown file and return structured HTML parts as a dictionary."""
try:
current_mtime = os.path.getmtime(file_path)
except OSError:
current_mtime = 0.0
with _markdown_lock:
cached = _markdown_cache.get(file_path)
if cached and cached['mtime'] == current_mtime:
return cached['data']
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
@ -586,7 +617,7 @@ def render_markdown_content(file_path):
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else ""
return {
parsed_data = {
'lesson_html': lesson_html,
'exercise_html': exercise_html,
'expected_output': expected_output,
@ -615,10 +646,21 @@ def render_markdown_content(file_path):
'slides': slides_html
}
with _markdown_lock:
if len(_markdown_cache) >= 128:
_markdown_cache.clear()
_markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime}
return parsed_data
@lru_cache(maxsize=1)
def render_home_content():
"""Render the home.md intro section (before Available_Lessons) as HTML."""
"""Render the home.md intro section (before Available_Lessons) as HTML.
Not cached separately relies on _read_home_md() mtime-based cache,
which is already fast (1 syscall per request). This avoids the lru_cache
multi-worker stale data problem.
"""
home_content = _read_home_md()
if not home_content:
return ""