Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 140 additions & 13 deletions .claude/hooks/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ test("cardToItem: reading=null when same as front", function (a) {
a.equal(cardToItem("v_1_0", {}).reading, null);
});
test("cardToItem: out-of-range day → null", function (a) {
a.equal(cardToItem("v_999_0", {}), null);
a.equal(cardToItem("v_9999_0", {}), null);
});
test("cardToItem: out-of-range vocab index → null", function (a) {
a.equal(cardToItem("v_1_999", {}), null);
Expand Down Expand Up @@ -312,8 +312,8 @@ test("srsDueCards: all overdue → all returned", function (a) {
});

// ── 12. Curriculum data integrity ─────────────────────────────────────────────
test("curriculum: not empty and has at least N3 (960 lessons)", function (a) {
a.ok(curriculum.length >= 960, "should have at least 960 lessons (N5 through N3 complete)");
test("curriculum: not empty and has at least N2 (1320 lessons)", function (a) {
a.ok(curriculum.length >= 1320, "should have at least 1320 lessons (N5 through N2 complete)");
});

test("curriculum: sequential day numbers starting from 1", function (a) {
Expand All @@ -339,7 +339,7 @@ test("curriculum: required string fields are non-empty on every lesson", functio
});

test("curriculum: type field is always a known string value", function (a) {
var valid = new Set(["script","lesson","grammar","kanji","review","numbers","particles","verbs","vocab"]);
var valid = new Set(["script","lesson","grammar","kanji","review","numbers","particles","verbs","vocab","reading"]);
var failures = [];
curriculum.forEach(function (l) {
if (!valid.has(l.type))
Expand Down Expand Up @@ -374,19 +374,19 @@ test("curriculum: chars entries are 2-element [char, reading] arrays", function
a.equal(failures.length, 0, failures.join("\n"));
});

test("curriculum: phaseNum is 1–20 on every lesson", function (a) {
test("curriculum: phaseNum is 1–32 on every lesson (N5 through N1)", function (a) {
var failures = [];
curriculum.forEach(function (l) {
if (typeof l.phaseNum !== "number" || l.phaseNum < 1 || l.phaseNum > 20)
if (typeof l.phaseNum !== "number" || l.phaseNum < 1 || l.phaseNum > 32)
failures.push("day " + l.day + " phaseNum=" + l.phaseNum);
});
a.equal(failures.length, 0, failures.join("\n"));
});

test("curriculum: week is 1–138 on every lesson", function (a) {
test("curriculum: week is 1–246 on every lesson", function (a) {
var failures = [];
curriculum.forEach(function (l) {
if (typeof l.week !== "number" || l.week < 1 || l.week > 138)
if (typeof l.week !== "number" || l.week < 1 || l.week > 246)
failures.push("day " + l.day + " week=" + l.week);
});
a.equal(failures.length, 0, failures.join("\n"));
Expand Down Expand Up @@ -418,9 +418,31 @@ test("curriculum: N4 ends at day 660", function (a) {
a.equal(curriculum[659].day, 660, "day 660 should be the last N4 lesson");
});

test("curriculum: N3 ends at day 960", function (a) {
test("curriculum: N3 ends at day 960, N2 complete at day 1320", function (a) {
a.equal(curriculum[959].day, 960, "day 960 should be the last N3 lesson");
a.equal(curriculum[curriculum.length - 1].day, 960, "last day is 960");
a.equal(curriculum[1319].day, 1320, "day 1320 should be the last N2 lesson");
a.equal(curriculum[curriculum.length - 1].day, 1320, "last day in curriculum is 1320");
});

test("curriculum: N2 phase boundaries (phases 21–26)", function (a) {
// Phase 21: N3 Review days 961–990
var ph21 = curriculum.slice(960, 990).filter(function (l) { return l.phaseNum !== 21; });
a.equal(ph21.length, 0, "days 961–990 all phaseNum=21");
// Phase 22: N2 Vocabulary days 991–1090
var ph22 = curriculum.slice(990, 1090).filter(function (l) { return l.phaseNum !== 22; });
a.equal(ph22.length, 0, "days 991–1090 all phaseNum=22");
// Phase 23: N2 Verbs days 1091–1140
var ph23 = curriculum.slice(1090, 1140).filter(function (l) { return l.phaseNum !== 23; });
a.equal(ph23.length, 0, "days 1091–1140 all phaseNum=23");
// Phase 24: N2 Grammar days 1141–1230
var ph24 = curriculum.slice(1140, 1230).filter(function (l) { return l.phaseNum !== 24; });
a.equal(ph24.length, 0, "days 1141–1230 all phaseNum=24");
// Phase 25: N2 Kanji days 1231–1275
var ph25 = curriculum.slice(1230, 1275).filter(function (l) { return l.phaseNum !== 25; });
a.equal(ph25.length, 0, "days 1231–1275 all phaseNum=25");
// Phase 26: N2 Test Prep days 1276–1320
var ph26 = curriculum.slice(1275, 1320).filter(function (l) { return l.phaseNum !== 26; });
a.equal(ph26.length, 0, "days 1276–1320 all phaseNum=26");
});

test("curriculum: N4 starts at day 366 (if present)", function (a) {
Expand All @@ -440,9 +462,12 @@ var EXPECTED_PHASE_NAMES = {
9: 'N5 Review', 10: 'N4 Vocabulary', 11: 'N4 Verbs',
12: 'N4 Grammar', 13: 'N4 Kanji', 14: 'N4 Test Prep',
15: 'N4 Review', 16: 'N3 Vocabulary', 17: 'N3 Verbs & Adjectives',
18: 'N3 Grammar', 19: 'N3 Kanji', 20: 'N3 Test Prep'
18: 'N3 Grammar', 19: 'N3 Kanji', 20: 'N3 Test Prep',
21: 'N3 Review', 22: 'N2 Vocabulary', 23: 'N2 Verbs & Expressions',
24: 'N2 Grammar', 25: 'N2 Kanji', 26: 'N2 Test Prep'
};
var PHASE_NUMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
var PHASE_NUMS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 23, 24, 25, 26];

test("PHASE_COLORS: defined with all phase keys as non-empty strings", function (a) {
a.ok(typeof PHASE_COLORS === "object" && PHASE_COLORS !== null, "PHASE_COLORS is an object");
Expand Down Expand Up @@ -476,7 +501,109 @@ test("phase constants: every phaseNum in curriculum is covered", function (a) {
});
});

// ── 14. React render smoke test ───────────────────────────────────────────────
// ── 14. furiganaHTML ──────────────────────────────────────────────────────────
test("furiganaHTML: returns ruby tags for kanji word", function (a) {
var r = furiganaHTML('食べる', 'たべる');
a.ok(r.indexOf('<ruby>') >= 0, 'contains ruby tag');
a.ok(r.indexOf('<rt>') >= 0, 'contains rt tag');
a.ok(r.indexOf('たべる') >= 0, 'contains reading');
});

test("furiganaHTML: returns plain text for hiragana-only word", function (a) {
a.equal(furiganaHTML('たべる', 'たべる'), 'たべる');
});

test("furiganaHTML: returns word when no reading provided", function (a) {
a.equal(furiganaHTML('test', null), 'test');
a.equal(furiganaHTML('test', ''), 'test');
});

// ── 15. dayToLevel ────────────────────────────────────────────────────────────
test("dayToLevel: N5 range (days 1–365)", function (a) {
a.equal(dayToLevel(1), 'N5');
a.equal(dayToLevel(365), 'N5');
});

test("dayToLevel: N4 range (days 366–660)", function (a) {
a.equal(dayToLevel(366), 'N4');
a.equal(dayToLevel(660), 'N4');
});

test("dayToLevel: N3 range (days 661–960)", function (a) {
a.equal(dayToLevel(661), 'N3');
a.equal(dayToLevel(960), 'N3');
});

test("dayToLevel: N2 range (days 961–1320)", function (a) {
a.equal(dayToLevel(961), 'N2');
a.equal(dayToLevel(1320), 'N2');
});

test("dayToLevel: N1 range (days 1321+)", function (a) {
a.equal(dayToLevel(1321), 'N1');
a.equal(dayToLevel(1720), 'N1');
});

// ── 16. exerciseCap ───────────────────────────────────────────────────────────
test("exerciseCap: N5/N4 cap is 5", function (a) {
a.equal(exerciseCap(1), 5);
a.equal(exerciseCap(660), 5);
});

test("exerciseCap: N3 cap is 7", function (a) {
a.equal(exerciseCap(661), 7);
a.equal(exerciseCap(960), 7);
});

test("exerciseCap: N2/N1 cap is 9", function (a) {
a.equal(exerciseCap(961), 9);
a.equal(exerciseCap(1320), 9);
});

// ── 17. buildExercises (N2 types) ─────────────────────────────────────────────
test("buildExercises N2: synonym exercise generated for N2 vocab lesson", function (a) {
var lesson = { day: 1050, type: 'vocab', chars: [],
vocab: [
['契約', 'けいやく', 'contract'], ['利益', 'りえき', 'profit'], ['投資', 'とうし', 'investment'],
['経営', 'けいえい', 'management'], ['売上', 'うりあげ', 'sales']
] };
var exs = buildExercises(lesson);
var types = exs.map(function (e) { return e.type; });
a.ok(types.indexOf('synonym') >= 0, 'synonym exercise generated for N2 vocab lesson');
});

test("buildExercises N2: kanji_reading exercise for N2 kanji lesson", function (a) {
var lesson = { day: 1240, type: 'kanji', vocab: [],
chars: [['裁', 'さい'], ['憲', 'けん'], ['権', 'けん'], ['議', 'ぎ'], ['税', 'ぜい']] };
var exs = buildExercises(lesson);
var types = exs.map(function (e) { return e.type; });
a.ok(types.indexOf('kanji_reading') >= 0, 'kanji_reading exercise generated for N2 kanji lesson');
});

test("buildExercises N2: exercise cap respected (max 9)", function (a) {
var lesson = curriculum[1000];
var exs = buildExercises(lesson);
a.ok(exs.length <= 9, 'N2 exercise count ' + exs.length + ' <= 9');
});

// ── 18. passage field validation ──────────────────────────────────────────────
test("passage: all reading-type days have text_jp and text_en", function (a) {
var failures = [];
curriculum.forEach(function (l) {
if (l.type !== 'reading') return;
if (!l.passage) {
failures.push('day ' + l.day + ': type=reading but no passage');
return;
}
if (!l.passage.text_jp || !l.passage.text_jp.trim())
failures.push('day ' + l.day + ': passage.text_jp empty');
if (!l.passage.text_en || !l.passage.text_en.trim())
failures.push('day ' + l.day + ': passage.text_en empty');
});
a.equal(failures.length, 0, failures.join('\n'));
});

// ── 20. React render smoke test ───────────────────────────────────────────────
// Extracts the inline <script> from index.html, runs it in a stubbed browser
// environment, then calls each top-level component to verify no globals are
// missing and no ReferenceError would blank the page.
Expand Down
30 changes: 20 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@

## Project overview

A free, self-contained, zero-dependency interactive Japanese course from zero to JLPT N3 in 960 days (N5 + N4 + N3 complete; N2/N1 planned).
A free, self-contained, zero-dependency interactive Japanese course from zero to JLPT N2 in 1,320 days (N5 + N4 + N3 + N2 complete; N1 planned).
Everything runs in the browser with no build step and no installation required.

## Repository structure

```
jlpt-n5/
├── index.html # Application shell — React components + localStorage logic
├── curriculum.js # 960-day lesson data array + phase colour/name constants
├── curriculum.js # 1,320-day lesson data array + phase colour/name constants
├── lib.js # Pure utility functions: SM-2, card helpers, exercises
├── tests.html # QUnit browser test suite (open directly, no server)
├── README.md # User-facing documentation
├── N3-N2-N1-REQUIREMENTS.md # Implementation plan for N2/N1 (N3 complete)
├── N3-N2-N1-REQUIREMENTS.md # Implementation plan for N2/N1 (N2 complete)
└── .nojekyll # Disables Jekyll processing for GitHub Pages
```

Expand Down Expand Up @@ -68,7 +68,7 @@ xdg-open tests.html # Linux
node .claude/hooks/run-tests.js
```

### Test coverage — 14 modules
### Test coverage — 20 modules

| Module | What is tested |
|---|---|
Expand All @@ -83,8 +83,13 @@ node .claude/hooks/run-tests.js
| `srsReview` | Quality→grade mapping, EF clamp, no mutation, new object |
| `srsAddCards` | Embedded card data, no-overwrite, bool return value |
| `srsDueCards` | Returns objects not IDs, due/not-due filtering |
| **Curriculum integrity** | 960 sequential days, required fields, type validity, vocab/chars structure, phase/week ranges, N5/N4/N3 boundary checks |
| **Phase constants** | PHASE_COLORS, PHASE_BG, PHASE_NAMES defined and correct for all 20 active phases |
| **Curriculum integrity** | 1,320 sequential days, required fields, type validity, vocab/chars structure, phase/week ranges, N5/N4/N3/N2 boundary checks |
| **Phase constants** | PHASE_COLORS, PHASE_BG, PHASE_NAMES defined and correct for all 26 active phases |
| **furiganaHTML** | Ruby tag generation, hiragana passthrough, null/empty reading handling |
| **dayToLevel** | Correct N5/N4/N3/N2/N1 mapping for boundary days |
| **exerciseCap** | Returns 5/7/9 for N5+N4/N3/N2+N1 respectively |
| **buildExercises (N3+ types)** | fill_blank, conjugation, pair_match; N2: synonym, kanji_reading; cap enforced |
| **passage rendering** | All reading-type days have text_jp and text_en |
| **React render** | index.html inline script executes, App/DayView/Overview/ReviewMode render without error |

## Curriculum structure
Expand All @@ -111,14 +116,19 @@ node .claude/hooks/run-tests.js
| 821–895 | 18 | N3 Grammar Patterns (~120 patterns) |
| 896–930 | 19 | N3 Kanji (~170 kanji) |
| 931–960 | 20 | N3 Test Prep |
| 961–990 | 21 | N3 Review (bridge to N2) |
| 991–1090 | 22 | N2 Vocabulary (~3,000 words) |
| 1091–1140 | 23 | N2 Verbs & Expressions |
| 1141–1230 | 24 | N2 Grammar Patterns (~180 patterns) |
| 1231–1275 | 25 | N2 Kanji (~200 kanji) |
| 1276–1320 | 26 | N2 Test Prep |

Phase constants for N2 (phases 21–26) and N1 (phases 27–32) are defined in
`curriculum.js` but lesson data for those levels has not yet been added.
See `N3-N2-N1-REQUIREMENTS.md` for the full N2/N1 implementation plan.
Phase constants for N1 (phases 27–32) are defined in `curriculum.js` but
lesson data has not yet been added. See `N3-N2-N1-REQUIREMENTS.md` for the N1 plan.

## Key implementation notes

- All 960 day definitions live in `curriculum.js` — the first 365 as a JSON array literal, days 366–960 appended via `curriculum.push()` calls
- All 1,320 day definitions live in `curriculum.js` — the first 365 as a JSON array literal, days 366–1,320 appended via `curriculum.push()` calls
- Two SM-2 implementations exist side-by-side: `sm2Update` (older, used by `ReviewView`) and `srsReview` (newer, used by `ReviewMode` + `App`). Both use `ease`/`ef` for the same concept.
- Quiz state, SRS card data, and completed-day flags are stored in `localStorage`
- The lesson view, overview calendar, and review flashcard deck are separate React components in `index.html`
Expand Down
44 changes: 23 additions & 21 deletions N3-N2-N1-REQUIREMENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# JLPT N3 / N2 / N1 — Implementation Requirements

> **Status:** N3 is fully implemented (days 661–960, phases 15–20, all tests passing).
> This document now serves as the implementation plan for **N2 and N1** only.
> N3 sections are retained for reference.
> N2 is fully implemented (days 961–1320, phases 21–26, all tests passing).
> This document now serves as the implementation plan for **N1** only.
> N3/N2 sections are retained for reference.

This document describes what was built to extend the course through N3, and what remains to be built for N2 and N1 readiness.

Expand All @@ -13,7 +14,7 @@ This document describes what was built to extend the course through N3, and what
| Level | New Vocabulary | New Kanji | New Grammar | New Days | Day Range | Status |
|-------|---------------|-----------|-------------|----------|-----------|--------|
| N3 | ~1,500 words | ~170 kanji | ~120 patterns | 300 | 661–960 | ✅ Complete |
| N2 | ~3,000 words | ~200 kanji | ~180 patterns | 360 | 961–1320 | Planned |
| N2 | ~3,000 words | ~200 kanji | ~180 patterns | 360 | 961–1320 | ✅ Complete |
| N1 | ~4,000 words | ~300 kanji | ~220 patterns | 400 | 1321–1720 | Planned |
| **Total** | **~8,500 words** | **~670 kanji** | **~520 patterns** | **1,060** | **661–1720** | |

Expand Down Expand Up @@ -792,12 +793,13 @@ To maintain consistency across all 1720 days:

| File | Changes | Status |
|------|---------|--------|
| `curriculum.js` | N3 days 661–960, phases 15–20 ✅; still needs N2/N1 days 961–1720, phases 21–32 | N3 done |
| `lib.js` | Add 11 new exercise types to `buildExercises()`, increase exercise caps, add furigana helper | Planned |
| `index.html` | Passage rendering, furigana toggle, level selector/filter, reading exercise UI, per-level progress | Planned |
| `tests.html` | Update day count checks as levels are added, add phase range checks, new exercise type tests | Ongoing |
| `CLAUDE.md` | Updated with 960-day curriculum structure and current test coverage | ✅ Done |
| `README.md` | Updated with N5/N4/N3 curriculum overview | ✅ Done |
| `curriculum.js` | N3 days 661–960 ✅; N2 days 961–1320 ✅; still needs N1 days 1321–1720, phases 27–32 | N3+N2 done |
| `lib.js` | 11 new exercise types in `buildExercises()` ✅, exercise caps ✅, furigana helper ✅, dayToLevel ✅ | ✅ Done |
| `index.html` | Passage rendering ✅, furigana toggle ✅, level selector/filter ✅, per-level progress ✅ | ✅ Done |
| `tests.html` | Day count updated to 1320 ✅, N2 phase range checks ✅, new exercise type tests ✅ | ✅ Done |
| `run-tests.js` | N2 phases, furiganaHTML, dayToLevel, exerciseCap, buildExercises N2+, passage validation ✅ | ✅ Done |
| `CLAUDE.md` | Updated with 1320-day curriculum structure and current test coverage | ✅ Done |
| `README.md` | Updated with N5/N4/N3/N2 curriculum overview | ✅ Done |

---

Expand All @@ -816,18 +818,18 @@ To maintain consistency across all 1720 days:
9. ✅ N3 Test Prep days (931–960)
10. ✅ Tests for N3 curriculum data integrity

### Phase B — N2 (days 961–1320)

11. Phase constants 21–26 in `curriculum.js`
12. N3 Review days (961–990)
13. N2 Vocabulary days (991–1090)
14. N2 Verbs & Expressions days (1091–1140)
15. N2 Grammar days (1141–1230)
16. N2 Kanji days (1231–1275)
17. New exercise types: `synonym`, `reorder`, `error_find`, `kanji_reading`
18. N2 Test Prep days (1276–1320)
19. UI level selector and per-level progress
20. Tests for N2 content and features
### Phase B — N2 (days 961–1320) ✅ Complete

11. Phase constants 21–26 in `curriculum.js`
12. N3 Review days (961–990)
13. N2 Vocabulary days (991–1090)
14. N2 Verbs & Expressions days (1091–1140)
15. N2 Grammar days (1141–1230)
16. N2 Kanji days (1231–1275)
17. New exercise types: `synonym`, `reorder`, `error_find`, `kanji_reading`
18. N2 Test Prep days (1276–1320)
19. UI level selector and per-level progress
20. Tests for N2 content and features (104 tests passing)

### Phase C — N1 (days 1321–1720)

Expand Down
Loading
Loading