Educational Webapp Builder
Overview
Build self-contained educational web apps using pure HTML/CSS/JS (no framework dependencies).
Apps are data-driven (content lives in separate JS data files), state persists via localStorage,
and modules can be added incrementally without breaking existing functionality.
Deployment target is CloudStudio cloud sandbox for long-lived public access.
Architecture
Directory Structure
app-name/
├── index.html # Single-page app shell: nav, page containers, script tags
├── css/
│ └── style.css # All styles, mobile-first responsive
├── js/
│ ├── app.js # Core logic: state, routing, rendering, scoring, animations
│ └── data/
│ ├── moduleA.js # Data as global JS objects (e.g. const CHINESE_DATA = {...})
│ ├── moduleB.js # Each file is independently loadable and validatable
│ └── ...
Data File Pattern
Each data file defines a global const and follows this shape:
// ========== 模块名称数据(N条) ==========
const MODULE_DATA = [
// Day 1
{
field1: 'value',
field2: ['item1', 'item2'],
questions: [
{ type: 'choice', q: 'question text', options: ['A','B','C','D'], answer: 1 },
{ type: 'short', q: 'question text', answer: 'reference answer' }
]
},
// Day 2 ...
];
Key rules:
- Data is plain JS objects, NOT JSON files — allows comments and template literals
- Each day's data is self-contained and independently validatable with node --check
- Use modulo indexing (day - 1) % DATA.length to cycle content across any number of days
- For generated content (e.g. math problems), include a generateProblems(day) function
HTML Structure Pattern
<!-- Sidebar nav -->
<div class="nav-item" data-page="moduleName" onclick="switchPage('moduleName')">
<span class="icon"> emoji </span><span>Module Name</span>
</div>
<!-- Page container (hidden by default, .active shows it) -->
<div class="page" id="page-moduleName">
<!-- module content -->
</div>
<!-- Script tags: data files BEFORE app.js -->
<script src="js/data/moduleA.js"></script>
<script src="js/data/moduleB.js"></script>
<script src="js/app.js"></script>
Core JS Patterns
State Object
let state = {
currentDay: 1,
currentPage: 'today',
totalScore: 0, // cumulative across all days
todayScore: 0, // reset when day changes
todayScoreDay: 1, // track which day todayScore belongs to
checkinDays: [], // [1, 2, 5, ...]
completedTasks: {}, // { d1: { taskId: true }, d2: {...} }
moduleDone: {}, // { d1: true } per-module completion tracking
moduleAnswers: {}, // { d1: { q0: 1, q1: 'text' } } saved answers
customTasks: [],
settings: { music: false, voice: true, flower: true },
letterIndex: 0,
pinyinIndex: 0
};
localStorage Persistence
const STORAGE_KEY = 'appName';
function saveState() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
catch(e) { console.warn('保存失败', e); }
}
function loadState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) Object.assign(state, JSON.parse(saved));
} catch(e) { console.warn('加载失败', e); }
}
Page Routing
function switchPage(page) {
state.currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
if (navItem) navItem.classList.add('active');
if (window.innerWidth <= 768) document.getElementById('sidebar').classList.remove('open');
renderPage(page);
}
function renderPage(page) {
switch(page) {
case 'today': renderTodayTasks(); break;
case 'moduleA': renderModuleA(); break;
// add new modules here
}
}
Day Switching with Score Reset
function changeDay(day) {
state.currentDay = parseInt(day);
// Reset today score when switching to a different day
if (state.todayScoreDay !== state.currentDay) {
state.todayScore = 0;
state.todayScoreDay = state.currentDay;
}
initDaySelectors();
saveState();
renderPage(state.currentPage);
updateScoreBoard();
updateTodayProgress();
}
Scoring Rules (per-module)
// Reading module: +2 for completing all questions, once per day
function submitReading() {
const dayKey = 'd' + state.currentDay;
if (state.readingDone[dayKey]) return; // prevent duplicate scoring
// ... validation ...
state.readingDone[dayKey] = true;
state.completedTasks[dayKey] = state.completedTasks[dayKey] || {};
state.completedTasks[dayKey]['rd-task'] = true;
state.todayScore += 2;
state.totalScore += 2;
saveState();
updateScoreBoard();
updateTodayProgress();
}
// Olympiad module: +2 for correct answer, once per day
function checkOlympiad() {
const dayKey = 'd' + state.currentDay;
if (state.olympiadDone[dayKey]) return;
// ... check answer ...
if (isCorrect) {
state.olympiadDone[dayKey] = true;
state.todayScore += 2;
state.totalScore += 2;
saveState();
updateScoreBoard();
updateTodayProgress();
}
}
// Daily check-in: +1 for completing all tasks, once per day
function checkAllTasks() {
const dayKey = 'd' + state.currentDay;
// Always mark all tasks done first
tasks.forEach(t => { state.completedTasks[dayKey][t.id] = true; });
// Score only once
if (!state.checkinDays.includes(state.currentDay)) {
state.checkinDays.push(state.currentDay);
state.totalScore += 1;
// celebrate
} else {
// already checked in today
}
}
Reset Today (keep history)
function resetToday() {
if (!confirm('确定要重置今日任务吗?\n将清空:今日任务状态 + 今日积分\n保留:历史累计总积分')) return;
const dayKey = 'd' + state.currentDay;
delete state.completedTasks[dayKey];
delete state.readingDone[dayKey];
delete state.olympiadDone[dayKey];
delete state.readingAnswers[dayKey];
state.todayScore = 0;
state.todayScoreDay = state.currentDay;
// totalScore unchanged
saveState();
renderPage(state.currentPage);
updateScoreBoard();
updateTodayProgress();
}
Init with Backward Compatibility
function init() {
loadState();
initDaySelectors();
// Ensure new fields exist for old saved state
if (state.todayScore === undefined) state.todayScore = 0;
if (state.todayScoreDay === undefined) state.todayScoreDay = state.currentDay;
if (state.readingDone === undefined) state.readingDone = {};
// ... other new fields ...
// Reset today score if day changed since last visit
if (state.todayScoreDay !== state.currentDay) {
state.todayScore = 0;
state.todayScoreDay = state.currentDay;
}
renderTodayTasks();
updateScoreBoard();
updateTodayProgress();
}
window.addEventListener('DOMContentLoaded', init);
Adding a New Module (Incremental)
To add a new module without breaking existing code:
- Create data file
js/data/newmodule.jswithconst NEWMODULE_DATA = [...] - Add nav item in
index.htmlsidebar - Add page container
<div class="page" id="page-newmodule">inindex.html - Add
<script>tag for the new data file, beforeapp.js - Add state fields in
app.js(e.g.newmoduleDone: {}) - Add to
renderPage()switch and implementrenderNewModule() - Add to
getTodayTasks()task list - Add to
renderTodayTasks()task section - Add to
initDaySelectors()if the module has a day selector - Add to
resetToday()to clean up new fields - Add to
init()backward-compat checks for new fields - Add CSS for new module styling
- Run
node --checkon all modified JS files
Deployment Workflow
Port Cleanup + Local Preview
# Check if port is in use
netstat -ano | grep ":8090" | grep LISTENING
# Kill residual processes on port
netstat -ano | grep ":8090" | grep LISTENING | awk '{print $5}' | sort -u | while read pid; do
taskkill //F //PID "$pid" 2>/dev/null
done
# Start local preview
cd /path/to/app && python -m http.server 8090
Cloud Deployment (CloudStudio)
Use workbuddy_cloudstudio_deploy tool with:
- directory: absolute path to app folder (must contain index.html)
- port: 3000 (sandbox internal port)
Returns a public URL like https://<hash>.gz5.agentos-app.net.
After deployment, verify:
curl -s -o /dev/null -w "%{http_code}" "https://<deployed-url>/index.html"
curl -s "https://<deployed-url>/js/app.js" | head -3
curl -s "https://<deployed-url>/js/data/newmodule.js" | head -3
Syntax Validation
Before deploying, always run:
cd /path/to/app && for f in js/data/*.js js/app.js; do
echo "=== $f ===" && node --check "$f" 2>&1 && echo "OK" || echo "SYNTAX ERROR"
done
Common Pitfalls
- Missing closing backtick in template literals —
node --checkwill catch this; usegrep -o '' file.js | wc -l` (should be even) to locate checkAllTasks()early return — neverreturnbefore scoring logic when all tasks are already checked; always proceed to score (guarded bycheckinDays.includes)init()missing calls — must callupdateScoreBoard()andupdateTodayProgress()afterloadState()to render initial stateresetProgress()incomplete — must reset ALL state fields including new module tracking (readingDone,olympiadDone, etc.), not justtotalScoreandcheckinDays- Script tag order — data files MUST load before
app.js Object.assign(state, parsed)— new fields added later won't exist in old saved state; always add backward-compat checks ininit()- Port conflicts on Windows —
taskkill //F //PID(double slash for Git Bash); if process won't die, switch to a new port
Content Conventions
- Use emoji icons in nav items and section headers for child-friendly UI
- Chinese text in content; code comments can be Chinese or English
- Font sizes: body 16px minimum for readability; headers 20-36px
- Mobile-first: sidebar collapses, pages stack vertically
- Gamification:鲜花动画 (flower animation), 语音鼓励 (TTS voice), 积分看板 (scoreboard)
- English words include phonetic transcription (British IPA)