← Back to App

Developer Guide

Overview

A static single-page web application for learning Chinese characters using spaced repetition. Built with vanilla HTML, CSS, and JavaScript - no build tools required.

Project Structure

├── 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

Running the Application

Start a local HTTP server from the project root:

python3 -m http.server 8765

Then open: http://localhost:8765/

Testing with MCP Playwright

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()

Running the Test Suite

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.

Data Schema

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.

localStorage Structure

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.

Adaptive Difficulty Implementation

The app uses an adaptive difficulty system that measures implicit signals to adjust intervals per-card. See SPACED_REPETITION.html for the conceptual overview.

Extended Data Model

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 */ }
}

Interval Calculation

// 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

Recording Result Algorithm

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
  };
}

Quick Reference: Outcome Matrix

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

Leech Detection

function isLeech(modeProgress) {
  const { lapseCount, difficultyScore, successCount } = modeProgress;
  return (lapseCount >= 4) || (difficultyScore >= 1.8 && successCount >= 6);
}

See SPACED_REPETITION.html for leech recovery strategies.

Key Features

Spaced Repetition with Review Priority

Multi-File Support

Two Test Modes

ModeShowsUser 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.

Mic Button States (READ Mode)

StateAppearanceMeaning
StartingGray button with spinnerWaiting for audio capture to be ready
ListeningRed pulsing micActively recording speech
ErrorGray with red XMic error - tap anywhere to retry

Speech Recognition Notes

Voice Selection

Choose from available Chinese TTS voices (zh-CN, zh-TW, zh-HK). Selection is persisted in localStorage.

Session Stats

Tracks correct/wrong counts per session (resets on page reload). Shows last practiced timestamp.

Import Behavior

Browser APIs Used

Browser Compatibility

Chrome 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.