A static single-page web application for learning Chinese characters using spaced repetition. Built with vanilla HTML, CSS, and JavaScript - no build tools required.
├── index.html # Main application
├── README.md # Project documentation
├── DEVELOPER.html # This file
├── SPACED_REPETITION.html # Algorithm documentation
├── Makefile # Build/test targets
├── tests/
│ └── flashcards.spec.ts # Playwright test suite
├── package.json # npm dependencies
└── playwright.config.ts # Playwright configuration
Start a local HTTP server from the project root:
python3 -m http.server 8765
Then open: http://localhost:8765/
The app can be tested using MCP Playwright tools:
// 1. Navigate to the app
mcp__playwright__browser_navigate({ url: "http://localhost:8765/" })
// 2. Upload flashcard JSON file
mcp__playwright__browser_click({ element: "Choose File button", ref: "e7" })
mcp__playwright__browser_file_upload({
paths: ["/path/to/flashcards.json"]
})
// 3. Click Play to start
mcp__playwright__browser_click({ element: "Play button", ref: "e12" })
// 4. Take snapshots to verify UI state
mcp__playwright__browser_snapshot()
// 5. Evaluate JavaScript state
mcp__playwright__browser_evaluate({
function: "() => JSON.parse(localStorage.getItem('progress') || '{}')"
})
// 6. Check console for errors
mcp__playwright__browser_console_messages()
// 7. Close browser when done
mcp__playwright__browser_close()
Run the automated Playwright tests:
make test # Install deps + run tests (headless)
make test-headed # Run tests with browser visible
make test-ui # Run tests with interactive UI
Note: Tests run in parallel with multiple browser instances, so you may hear overlapping speech synthesis voices. This is normal.
The app expects JSON files with this structure:
interface Flashcard {
word: string; // Chinese character (e.g., "你")
word_hanyupinyin: string; // Pinyin with tone marks (e.g., "nǐ")
word_english: string; // English translation (e.g., "you")
sentence: string; // Chinese sentence (e.g., "你好!")
sentence_hanyupinyin: string; // Sentence pinyin (e.g., "Nǐ hǎo!")
sentence_english: string; // English translation (e.g., "Hello!")
label?: string; // Optional grade/category (e.g., "一上")
}
type FlashcardList = Flashcard[];
The optional label field can indicate grade level (e.g., "一上" for Primary 1 First Semester) and is displayed on flashcards during practice.
All data is stored under the key flashcard_files:
// localStorage.getItem('flashcard_files')
[
{
id: "unique-id",
name: "My Flashcard Set",
flashcards: [...], // Array of Flashcard objects
lastAttempted: 1234567890, // Timestamp of last practice
highscore: { count: 5, timestamp: 1234567890 }, // Best streak
progress: {
"你": {
read: { /* see Extended Data Model below */ },
write: { /* see Extended Data Model below */ }
}
}
}
]
// localStorage.getItem('flashcard_voice')
"Tingting" // Selected TTS voice name
See Extended Data Model for full progress field structure including adaptive difficulty tracking.
The app uses an adaptive difficulty system that measures implicit signals to adjust intervals per-card. See SPACED_REPETITION.html for the conceptual overview.
Each card's progress includes difficulty tracking fields:
progress[word] = {
read: {
intervalIndex: 0,
nextReview: timestamp,
successCount: 0,
failCount: 0,
// Adaptive difficulty fields
lapseCount: 0, // Failures after reaching level 2+
avgResponseTime: null, // Rolling average in milliseconds
lastResponseTime: null,
hintUseCount: 0, // Times hint used (levels 0-1 only)
difficultyScore: 1.0 // Multiplier: 0.5 (easy) to 2.0 (hard)
},
write: { /* same structure */ }
}
// Base intervals (milliseconds)
const INTERVALS = [
1 * 60 * 1000, // 0: 1 minute
10 * 60 * 1000, // 1: 10 minutes
60 * 60 * 1000, // 2: 1 hour
24 * 60 * 60 * 1000, // 3: 1 day
3 * 24 * 60 * 60 * 1000, // 4: 3 days (mastered threshold)
7 * 24 * 60 * 60 * 1000, // 5: 7 days
14 * 24 * 60 * 60 * 1000, // 6: 14 days
30 * 24 * 60 * 60 * 1000, // 7: 30 days
60 * 24 * 60 * 60 * 1000, // 8: 60 days
90 * 24 * 60 * 60 * 1000 // 9: 90 days (maximum)
];
// Difficulty-adjusted interval
function getNextInterval(intervalIndex, difficultyScore) {
const baseInterval = INTERVALS[intervalIndex];
// Easy cards (score < 1): longer intervals
// Hard cards (score > 1): shorter intervals
return baseInterval / difficultyScore;
}
// Example:
// difficultyScore 0.5 (easy): 7-day becomes 14 days
// difficultyScore 2.0 (hard): 7-day becomes 3.5 days
function recordResult(correct, responseTime, usedHint, modeProgress) {
const { intervalIndex, avgResponseTime, difficultyScore, lapseCount } = modeProgress;
const wasLearned = intervalIndex >= 2;
// ═══════════════════════════════════════════════════════════════
// STEP 1: Classify response time
// ═══════════════════════════════════════════════════════════════
let timeCategory; // 'fast' | 'normal' | 'slow'
if (avgResponseTime === null) {
timeCategory = 'normal'; // No baseline yet
} else if (responseTime < avgResponseTime * 0.7) {
timeCategory = 'fast'; // 30% faster than average
} else if (responseTime > avgResponseTime * 1.5) {
timeCategory = 'slow'; // 50% slower than average
} else {
timeCategory = 'normal';
}
// ═══════════════════════════════════════════════════════════════
// STEP 2: Calculate level advancement
// ═══════════════════════════════════════════════════════════════
let levelDelta;
let newLapseCount = lapseCount;
if (!correct) {
levelDelta = -intervalIndex; // Reset to 0
if (wasLearned) newLapseCount++;
} else if (usedHint) {
levelDelta = 0; // No advancement when hint used
} else if (timeCategory === 'fast') {
levelDelta = 2; // Skip ahead for fast recall
} else {
levelDelta = 1; // Standard advancement
}
const newIntervalIndex = Math.max(0, Math.min(9, intervalIndex + levelDelta));
// ═══════════════════════════════════════════════════════════════
// STEP 3: Update difficulty score
// ═══════════════════════════════════════════════════════════════
let newDifficultyScore = difficultyScore;
if (!correct) {
newDifficultyScore *= 1.2; // Failed → harder
} else if (usedHint) {
newDifficultyScore *= 1.1; // Needed help → harder
} else if (timeCategory === 'fast') {
newDifficultyScore *= 0.9; // Fast recall → easier
} else if (timeCategory === 'slow') {
newDifficultyScore *= 1.05; // Slow recall → slightly harder
}
// Normal speed correct: no change
newDifficultyScore = Math.max(0.5, Math.min(2.0, newDifficultyScore));
// ═══════════════════════════════════════════════════════════════
// STEP 4: Calculate next review time
// ═══════════════════════════════════════════════════════════════
const baseInterval = INTERVALS[newIntervalIndex];
const adjustedInterval = baseInterval / newDifficultyScore;
// Add fuzz factor (±5%) to prevent review clustering
const fuzz = 0.95 + Math.random() * 0.1;
const finalInterval = adjustedInterval * fuzz;
const nextReview = Date.now() + finalInterval;
// ═══════════════════════════════════════════════════════════════
// STEP 5: Update rolling average response time
// ═══════════════════════════════════════════════════════════════
let newAvgResponseTime;
if (avgResponseTime === null) {
newAvgResponseTime = responseTime;
} else {
newAvgResponseTime = avgResponseTime * 0.8 + responseTime * 0.2;
}
// ═══════════════════════════════════════════════════════════════
// STEP 6: Return updated progress
// ═══════════════════════════════════════════════════════════════
return {
intervalIndex: newIntervalIndex,
nextReview: nextReview,
successCount: correct ? modeProgress.successCount + 1 : modeProgress.successCount,
failCount: correct ? modeProgress.failCount : modeProgress.failCount + 1,
lapseCount: newLapseCount,
avgResponseTime: newAvgResponseTime,
lastResponseTime: responseTime,
hintUseCount: usedHint ? modeProgress.hintUseCount + 1 : modeProgress.hintUseCount,
difficultyScore: newDifficultyScore
};
}
| Scenario | Level Δ | Difficulty Δ | Lapse? |
|---|---|---|---|
| Fast + No hint + Correct | +2 |
× 0.9 |
No |
| Normal + No hint + Correct | +1 |
× 1.0 |
No |
| Slow + No hint + Correct | +1 |
× 1.05 |
No |
| Any + Hint + Correct | +0 |
× 1.1 |
No |
| Any + Any + Wrong | → 0 |
× 1.2 |
If level ≥ 2 |
function isLeech(modeProgress) {
const { lapseCount, difficultyScore, successCount } = modeProgress;
return (lapseCount >= 4) || (difficultyScore >= 1.8 && successCount >= 6);
}
See SPACED_REPETITION.html for leech recovery strategies.
| Mode | Shows | User Action |
|---|---|---|
| READ | Chinese word + sentence | Auto-starts mic (spinner → red mic when ready), just speak. Partial match highlighting shows progress. "Show hint" reveals pinyin/English. On wrong answer, speaks word then sentence with highlighting. |
| WRITE | English + pinyin | Recall Chinese, use "Say it" to hear pronunciation, then self-grade (Correct/Wrong). On wrong answer, speaks word then sentence with highlighting. |
| State | Appearance | Meaning |
|---|---|---|
| Starting | Gray button with spinner | Waiting for audio capture to be ready |
| Listening | Red pulsing mic | Actively recording speech |
| Error | Gray with red X | Mic error - tap anywhere to retry |
Choose from available Chinese TTS voices (zh-CN, zh-TW, zh-HK). Selection is persisted in localStorage.
Tracks correct/wrong counts per session (resets on page reload). Shows last practiced timestamp.
exportedAt) restore progress; plain JSON imports as new setChrome and Safari (including iOS Safari) fully support all features. Firefox does not support the Web Speech API for speech recognition, so READ mode will not function in Firefox.