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
37 changes: 26 additions & 11 deletions docs/TEACHING_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,39 @@ reading-by-rhythm skill shows.

### Speed certificates

Independent of stage progress, three certificates mark solid-copy milestones at
the historic FCC licence speeds — **5, 13 and 20 WPM** (Novice / General /
Amateur Extra). They are not one-session awards: a certificate is earned only
after **three** qualifying group sessions of 125+ characters at ≥90% accuracy
with a recorded character speed at or above the certificate speed.

Unearned certificates stay out of the badge row; the panel explains the
criteria and shows progress toward the next speed. Sessions without a recorded
`charWpm` never count — speed must be known.
Certificates form a **matrix of (stage × speed)** with the historic FCC exam
speeds — **5, 13 and 20 WPM** (Novice / General / Amateur Extra). The historic
tests presumed the complete alphabet, so the final row (full character set) is
the true equivalent; it also unlocks trophy-case achievements
(`solid-copy-5wpm` / `-13wpm` / `-20wpm`). Earlier rows give learners a
certificate target at every stage of the ladder.

A cell is earned by a **single session** — one sustained run, like the real
exam, never an accumulation across sessions:

- 100+ characters copied **correctly** from groups that were actually *played*
at or above the certificate speed,
- ≥90% accuracy among those at-speed characters,
- in a session at or above the stage's exit level.

Because speed settings can be ranges, each group records the speed it was
actually played at (`groupTimings[].charWpm`) — with variable speed, only the
groups that met the certificate speed count toward the run. Legacy sessions
without per-group speeds fall back to the session-level snapshot (the range
minimum, conservative); history with no speed data never earns certificates —
the plan does not guess.

The panel shows best-attempt progress per cell (e.g. `87/100`), and the
results screen calls out near-misses ("13 short — one more run?") so a failed
attempt turns into the next session's goal.

## UI

`TeachingPlanPanel` (mounted on the group-training home screen) shows:

- overall plan progress (stages completed, progress bar),
- the active stage with its goal checklist and best copy-test accuracy,
- the speed-certificate section (criteria, progress toward the next speed, and
earned badges only).
- the certificate row with earned/unearned state.

## Future extensions

Expand Down
33 changes: 27 additions & 6 deletions lib/morseAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,29 @@ export async function playMorseCodeControlled(
shouldStop: () => boolean,
outputNode?: AudioNode,
onStopReady?: (stop: () => void) => void,
): Promise<{ durationSec: number; startTime: number; stop: () => void }> {
if (shouldStop()) return { durationSec: 0, startTime: ctx.currentTime, stop: () => {} };

await ensureContext(ctx);

): Promise<{
durationSec: number;
startTime: number;
stop: () => void;
/** Character speed this playback actually used (ranges are sampled per call). */
resolvedCharWpm: number;
/** Effective (Farnsworth) speed this playback actually used. */
resolvedEffectiveWpm: number;
}> {
// Determine timing based on Farnsworth (supports random ranges)
const resolvedCharWpm = resolveCharWpm(settings);
const resolvedEffWpm = resolveEffectiveWpm(settings, resolvedCharWpm);

if (shouldStop())
return {
durationSec: 0,
startTime: ctx.currentTime,
stop: () => {},
resolvedCharWpm,
resolvedEffectiveWpm: resolvedEffWpm,
};

await ensureContext(ctx);
const extraWordSpaceMultiplier = clampExtraSpacingMultiplier(settings.extraWordSpaceMultiplier);

const dotChar = 1.2 / resolvedCharWpm; // seconds
Expand Down Expand Up @@ -256,7 +271,13 @@ export async function playMorseCodeControlled(

// Return duration and the actual start time (AudioContext) used for scheduling,
// so callers can compute precise end time (startTime + durationSec) for reaction timing.
return { durationSec: currentTime - startTime, startTime, stop };
return {
durationSec: currentTime - startTime,
startTime,
stop,
resolvedCharWpm,
resolvedEffectiveWpm: resolvedEffWpm,
};
}


Expand Down
26 changes: 15 additions & 11 deletions lib/sessionPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,17 +111,21 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi
: sent.length > 0 && sent === received;
return { sent, received, correct };
});
const groupTimings: Array<{ timeToCompleteMs: number; perCharMs?: number }> = (() => {
if (Array.isArray(rawObj['groupTimings'])) {
return (rawObj['groupTimings'] as unknown[]).map((t: any) => ({
timeToCompleteMs: Math.max(0, Number(t?.timeToCompleteMs) || 0),
...(typeof t?.perCharMs === 'number' && isFinite(t.perCharMs) && t.perCharMs >= 0
? { perCharMs: Number(t.perCharMs) }
: {}),
}));
}
return groups.map(() => ({ timeToCompleteMs: 0 }));
})();
const groupTimings: Array<{ timeToCompleteMs: number; perCharMs?: number; charWpm?: number }> =
(() => {
if (Array.isArray(rawObj['groupTimings'])) {
return (rawObj['groupTimings'] as unknown[]).map((t: any) => ({
timeToCompleteMs: Math.max(0, Number(t?.timeToCompleteMs) || 0),
...(typeof t?.perCharMs === 'number' && isFinite(t.perCharMs) && t.perCharMs >= 0
? { perCharMs: Number(t.perCharMs) }
: {}),
...(typeof t?.charWpm === 'number' && isFinite(t.charWpm) && t.charWpm >= 1
? { charWpm: Number(t.charWpm) }
: {}),
}));
}
return groups.map(() => ({ timeToCompleteMs: 0 }));
})();
const safeAccuracy = (() => {
if (typeof rawObj['accuracy'] === 'number' && isFinite(rawObj['accuracy'] as number))
return rawObj['accuracy'] as number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe('ToneEnvelopeSection', () => {
durationSec: 0.1,
startTime: 0,
stop: jest.fn(),
resolvedCharWpm: 20,
resolvedEffectiveWpm: 20,
});
class MockAudioContext {
public readonly close = jest.fn().mockResolvedValue(undefined);
Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/hooks/useTrainingAudio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe('useTrainingAudio', () => {
durationSec: 0.5,
startTime: 0,
stop: jest.fn(),
resolvedCharWpm: 20,
resolvedEffectiveWpm: 20,
});
});

Expand All @@ -50,7 +52,7 @@ describe('useTrainingAudio', () => {

const playback = await result.current.playMorse('KM', 0);

expect(playback).toEqual({ status: 'played', durationSec: 0.5 });
expect(playback).toEqual({ status: 'played', durationSec: 0.5, charWpm: 20, effectiveWpm: 20 });
expect(mockPlayMorse).toHaveBeenCalled();
});

Expand All @@ -68,7 +70,7 @@ describe('useTrainingAudio', () => {
const stop = jest.fn();
onStopReady?.(stop);
capturedStop = stop;
return { durationSec: 0.5, startTime: 0, stop };
return { durationSec: 0.5, startTime: 0, stop, resolvedCharWpm: 20, resolvedEffectiveWpm: 20 };
},
);

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/hooks/useTrainingSession.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ describe('useTrainingSession', () => {

it('waits for scheduled playback duration before unlocking input', async () => {
const audio = createMockAudio();
jest.mocked(audio.playMorse).mockResolvedValue({ status: 'played', durationSec: 2.5 });
jest.mocked(audio.playMorse).mockResolvedValue({ status: 'played', durationSec: 2.5, charWpm: 20, effectiveWpm: 20 });
mockUseTrainingAudio.mockReturnValue(audio);

const { result } = renderSessionHook();
Expand Down Expand Up @@ -888,7 +888,7 @@ describe('useTrainingSession', () => {
jest.mocked(audio.playMorse).mockImplementation(
() =>
new Promise((resolve) => {
playResolvers.push(() => resolve({ status: 'played', durationSec: 0 }));
playResolvers.push(() => resolve({ status: 'played', durationSec: 0, charWpm: 20, effectiveWpm: 20 }));
}),
);
mockUseTrainingAudio.mockReturnValue(audio);
Expand Down
149 changes: 149 additions & 0 deletions src/__tests__/lib/curriculum/speedCertificates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
attemptedStageForLevel,
buildTeachingPlan,
evaluateSessionSpeedRun,
evaluateSpeedCertificateMatrix,
} from '@/lib/curriculum';
import type { SessionResult, SessionGroup, SessionTiming } from '@/types';

/** Build a session from parallel groups/timings; defaults to a full-alphabet level. */
const makeSession = ({
timestamp,
groups,
timings,
kochLevel = 40,
sessionCharWpm,
mode,
}: {
readonly timestamp: number;
readonly groups: readonly SessionGroup[];
readonly timings: readonly SessionTiming[];
readonly kochLevel?: number;
readonly sessionCharWpm?: number;
readonly mode?: 'group' | 'echo' | 'chase';
}): SessionResult => ({
date: '2026-07-01',
timestamp,
startedAt: timestamp - 60_000,
finishedAt: timestamp,
groups: [...groups],
groupTimings: [...timings],
accuracy: 1,
letterAccuracy: {},
alphabetSize: 26,
avgResponseMs: 1500,
totalChars: groups.reduce((sum, g) => sum + g.sent.length, 0),
effectiveAlphabetSize: 26,
score: 100,
kochLevel,
...(sessionCharWpm !== undefined ? { charWpm: sessionCharWpm } : {}),
...(mode !== undefined ? { mode } : {}),
});

const correctGroup = (sent: string): SessionGroup => ({ sent, received: sent, correct: true });

/** N correct 5-char groups at the given per-group speed. */
const groupsAtSpeed = (
count: number,
wpm: number,
): { groups: SessionGroup[]; timings: SessionTiming[] } => ({
groups: Array.from({ length: count }, () => correctGroup('KMURE')),
timings: Array.from({ length: count }, () => ({ timeToCompleteMs: 1000, charWpm: wpm })),
});

describe('evaluateSessionSpeedRun', () => {
it('counts only characters from groups played at or above the certificate speed', () => {
const fast = groupsAtSpeed(10, 20); // 50 chars at 20 WPM
const slow = groupsAtSpeed(10, 10); // 50 chars at 10 WPM
const session = makeSession({
timestamp: 1,
groups: [...fast.groups, ...slow.groups],
timings: [...fast.timings, ...slow.timings],
});
const at20 = evaluateSessionSpeedRun(session, 20);
expect(at20.totalChars).toBe(50);
const at10 = evaluateSessionSpeedRun(session, 10);
expect(at10.totalChars).toBe(100);
});

it('requires 100 correct at-speed characters within the single session', () => {
const nineteen = groupsAtSpeed(19, 20); // 95 chars — just short
const short = makeSession({ timestamp: 1, groups: nineteen.groups, timings: nineteen.timings });
expect(evaluateSessionSpeedRun(short, 20).achieved).toBe(false);

const twenty = groupsAtSpeed(20, 20); // 100 chars
const enough = makeSession({ timestamp: 2, groups: twenty.groups, timings: twenty.timings });
expect(evaluateSessionSpeedRun(enough, 20).achieved).toBe(true);
});

it('requires at-speed accuracy, not just volume', () => {
const { groups, timings } = groupsAtSpeed(30, 20); // 150 chars
const flawed = groups.map((g, i) =>
i < 10 ? { sent: g.sent, received: 'XXXXX', correct: false } : g,
);
const session = makeSession({ timestamp: 1, groups: flawed, timings });
const run = evaluateSessionSpeedRun(session, 20);
expect(run.correctChars).toBe(100);
expect(run.accuracy).toBeCloseTo(100 / 150);
expect(run.achieved).toBe(false); // 67% at-speed accuracy < 90%
});

it('falls back to the session-level speed snapshot for legacy sessions', () => {
const groups = Array.from({ length: 25 }, () => correctGroup('KMURE'));
const timings = groups.map(() => ({ timeToCompleteMs: 1000 })); // no per-group speed
const legacy = makeSession({ timestamp: 1, groups, timings, sessionCharWpm: 15 });
expect(evaluateSessionSpeedRun(legacy, 13).achieved).toBe(true);
expect(evaluateSessionSpeedRun(legacy, 20).totalChars).toBe(0);
});
});

describe('evaluateSpeedCertificateMatrix', () => {
const plan = buildTeachingPlan();

it('credits the stage bracket the session level reaches, and all below it', () => {
const { groups, timings } = groupsAtSpeed(25, 13);
const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 12 })];
const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
// Level 12 covers stages with exit level <= 12 (stages 1 and 2)
expect(matrix.rows[0]!.cells.find((c) => c.speed === 13)!.earned).toBe(true);
expect(matrix.rows[1]!.cells.find((c) => c.speed === 13)!.earned).toBe(true);
expect(matrix.rows[2]!.cells.find((c) => c.speed === 13)!.earned).toBe(false);
// Faster speeds not earned
expect(matrix.rows[0]!.cells.find((c) => c.speed === 20)!.earned).toBe(false);
});

it('tracks the best near-miss attempt for progress display', () => {
const { groups, timings } = groupsAtSpeed(15, 20); // 75 correct chars — short of 100
const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 40 })];
const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
const lastRow = matrix.rows[matrix.rows.length - 1]!;
const cell = lastRow.cells.find((c) => c.speed === 20)!;
expect(cell.earned).toBe(false);
expect(cell.bestCorrectChars).toBe(75);
});

it('marks full-alphabet certificates from the final stage row', () => {
const { groups, timings } = groupsAtSpeed(20, 20);
const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 40 })];
const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
expect(matrix.fullAlphabetEarnedSpeeds).toEqual([5, 13, 20]);
});

it('ignores echo/chase sessions', () => {
const { groups, timings } = groupsAtSpeed(20, 20);
const sessions = [makeSession({ timestamp: 1, groups, timings, mode: 'echo' })];
const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
expect(matrix.earnedCount).toBe(0);
});
});

describe('attemptedStageForLevel', () => {
const plan = buildTeachingPlan();

it('returns the highest stage whose exit level is reached', () => {
expect(attemptedStageForLevel(plan, 3)).toBeNull(); // below first exit level (5)
expect(attemptedStageForLevel(plan, 5)!.index).toBe(0);
expect(attemptedStageForLevel(plan, 12)!.index).toBe(1);
expect(attemptedStageForLevel(plan, 40)!.index).toBe(plan.length - 1);
});
});
36 changes: 5 additions & 31 deletions src/__tests__/lib/curriculum/teachingPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,36 +118,10 @@ describe('evaluateTeachingPlan', () => {
expect(quality.current).toBe(0);
});

it('awards speed certificates only after repeated solid-copy sessions at speed', () => {
const oneSession = [
makeSession({ timestamp: 1, kochLevel: 3, charWpm: 15, accuracy: 0.94, totalChars: 130 }),
];
const afterOne = evaluateTeachingPlan(oneSession, 3);
const byWpmAfterOne = new Map(afterOne.certificates.map((c) => [c.wpm, c]));
expect(byWpmAfterOne.get(5)!.earned).toBe(false);
expect(byWpmAfterOne.get(5)!.current).toBe(1);
expect(byWpmAfterOne.get(13)!.current).toBe(1);
expect(byWpmAfterOne.get(20)!.current).toBe(0);

const threeSessions = [
makeSession({ timestamp: 1, kochLevel: 3, charWpm: 15, accuracy: 0.94, totalChars: 130 }),
makeSession({ timestamp: 2, kochLevel: 3, charWpm: 15, accuracy: 0.91, totalChars: 130 }),
makeSession({ timestamp: 3, kochLevel: 3, charWpm: 14, accuracy: 0.93, totalChars: 140 }),
];
const progress = evaluateTeachingPlan(threeSessions, 3);
const byWpm = new Map(progress.certificates.map((c) => [c.wpm, c]));
expect(byWpm.get(5)!.earned).toBe(true);
expect(byWpm.get(13)!.earned).toBe(true);
expect(byWpm.get(20)!.earned).toBe(false);
expect(byWpm.get(5)!.sessionTimestamp).toBe(3);
});

it('does not award certificates without volume or speed data', () => {
const sessions = [
makeSession({ timestamp: 1, kochLevel: 3, accuracy: 0.99, totalChars: 500 }), // no charWpm
makeSession({ timestamp: 2, kochLevel: 3, charWpm: 25, accuracy: 0.99, totalChars: 50 }), // too short
];
const progress = evaluateTeachingPlan(sessions, 3);
expect(progress.certificates.every((c) => !c.earned && c.current === 0)).toBe(true);
it('exposes the speed-certificate matrix with one row per stage', () => {
const progress = evaluateTeachingPlan([], 1);
expect(progress.certificates.rows.length).toBe(progress.stages.length);
expect(progress.certificates.earnedCount).toBe(0);
expect(progress.certificates.rows[0]!.cells.map((c) => c.speed)).toEqual([5, 13, 20]);
});
});
Loading
Loading