diff --git a/docs/TEACHING_PLAN.md b/docs/TEACHING_PLAN.md index d40ee65..17628a5 100644 --- a/docs/TEACHING_PLAN.md +++ b/docs/TEACHING_PLAN.md @@ -43,15 +43,31 @@ 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 @@ -59,8 +75,7 @@ criteria and shows progress toward the next speed. Sessions without a recorded - 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 diff --git a/lib/morseAudio.ts b/lib/morseAudio.ts index 68471dd..3796ec2 100644 --- a/lib/morseAudio.ts +++ b/lib/morseAudio.ts @@ -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 @@ -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, + }; } diff --git a/lib/sessionPersistence.ts b/lib/sessionPersistence.ts index c89aebd..07a4b2f 100644 --- a/lib/sessionPersistence.ts +++ b/lib/sessionPersistence.ts @@ -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; diff --git a/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx b/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx index 9109367..2eabf21 100644 --- a/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx +++ b/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx @@ -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); diff --git a/src/__tests__/hooks/useTrainingAudio.test.ts b/src/__tests__/hooks/useTrainingAudio.test.ts index ea4976c..6ae1992 100644 --- a/src/__tests__/hooks/useTrainingAudio.test.ts +++ b/src/__tests__/hooks/useTrainingAudio.test.ts @@ -31,6 +31,8 @@ describe('useTrainingAudio', () => { durationSec: 0.5, startTime: 0, stop: jest.fn(), + resolvedCharWpm: 20, + resolvedEffectiveWpm: 20, }); }); @@ -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(); }); @@ -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 }; }, ); diff --git a/src/__tests__/hooks/useTrainingSession.test.tsx b/src/__tests__/hooks/useTrainingSession.test.tsx index 1448868..6b71884 100644 --- a/src/__tests__/hooks/useTrainingSession.test.tsx +++ b/src/__tests__/hooks/useTrainingSession.test.tsx @@ -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(); @@ -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); diff --git a/src/__tests__/lib/curriculum/speedCertificates.test.ts b/src/__tests__/lib/curriculum/speedCertificates.test.ts new file mode 100644 index 0000000..e9f5c9f --- /dev/null +++ b/src/__tests__/lib/curriculum/speedCertificates.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/lib/curriculum/teachingPlan.test.ts b/src/__tests__/lib/curriculum/teachingPlan.test.ts index b157b5c..474b331 100644 --- a/src/__tests__/lib/curriculum/teachingPlan.test.ts +++ b/src/__tests__/lib/curriculum/teachingPlan.test.ts @@ -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]); }); }); diff --git a/src/components/features/training/TeachingPlanPanel.tsx b/src/components/features/training/TeachingPlanPanel.tsx index b960483..3319bb4 100644 --- a/src/components/features/training/TeachingPlanPanel.tsx +++ b/src/components/features/training/TeachingPlanPanel.tsx @@ -2,14 +2,8 @@ import React, { useMemo, useState } from 'react'; -import { - CERTIFICATE_MIN_CHARS, - CERTIFICATE_SESSIONS_TARGET, - QUALITY_ACCURACY, - buildTeachingPlan, - evaluateTeachingPlan, -} from '@/lib/curriculum'; -import type { SpeedCertificateProgress, StageProgress } from '@/lib/curriculum'; +import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum'; +import type { SpeedCertificateMatrix, StageProgress } from '@/lib/curriculum'; import type { SessionResult, TrainingSettings } from '@/types'; export interface TeachingPlanPanelProps { @@ -159,72 +153,121 @@ export function TeachingPlanPanel({ sessions, settings }: TeachingPlanPanelProps ) : null} - + ); } -function SpeedCertificatesSection({ - certificates, +/** + * Speed-certificate matrix: one row per stage, one column per exam speed. + * A cell is earned by a SINGLE session — 100+ characters copied correctly from + * groups played at or above that speed, at ≥90% at-speed accuracy, at or above + * the stage's exit level. The final row spans the full character set: those are + * the historic 5/13/20 WPM certificates and unlock trophies. + */ +function CertificateMatrixTable({ + matrix, + activeStageIndex, + showAll, }: { - readonly certificates: readonly SpeedCertificateProgress[]; + readonly matrix: SpeedCertificateMatrix; + readonly activeStageIndex: number; + readonly showAll: boolean; }): JSX.Element { - const earned = certificates.filter((cert) => cert.earned); - const nextTarget = certificates.find((cert) => !cert.earned); - const accuracyPct = Math.round(QUALITY_ACCURACY * 100); + const lastIndex = matrix.rows.length - 1; + const visibleRows = showAll + ? matrix.rows + : matrix.rows.filter( + (row) => + row.stage.index <= activeStageIndex || + row.stage.index === lastIndex || + row.cells.some((cell) => cell.earned), + ); return ( -
+

Speed certificates

- {earned.map((cert) => ( - - 🏅 {cert.wpm} WPM - - ))} - {earned.length > 0 ? ( + + {matrix.earnedCount}/{matrix.totalCells} earned + + {matrix.earnedCount > 0 ? ( ) : null}
-

- Solid-copy milestones at the historic FCC exam speeds — 5, 13, and 20 WPM (Novice / - General / Extra). Earn one by completing {CERTIFICATE_SESSIONS_TARGET} sessions of{' '} - {CERTIFICATE_MIN_CHARS}+ characters at ≥{accuracyPct}% accuracy with character speed at - that rate or faster. +

+ + + + + {matrix.rows[0]?.cells.map((cell) => ( + + ))} + + + + {visibleRows.map((row) => ( + + + {row.cells.map((cell) => ( + + ))} + + ))} + +
Stage + {cell.speed} WPM +
+ {row.stage.index === lastIndex ? '★ Full set' : `Stage ${row.stage.index + 1}`} + + {cell.earned ? ( + + 🏅 + + ) : cell.bestCorrectChars > 0 ? ( + + {cell.bestCorrectChars}/{cell.targetChars} + + ) : ( + + )} +
+
+

+ Earned in a single session: {matrix.rows[0]?.cells[0]?.targetChars ?? 100}+ characters + copied correctly from groups played at the certificate speed, ≥90% accuracy at speed. With + variable speed only the groups that met the speed count.

- {nextTarget !== undefined ? ( -

- Next: {nextTarget.wpm} WPM — {nextTarget.current}/{nextTarget.target} qualifying - sessions -

- ) : ( -

- All speed certificates earned. -

- )}
); } -async function shareCertificates( - certificates: readonly SpeedCertificateProgress[], -): Promise { - const speeds = certificates.map((cert) => `${cert.wpm} WPM`); - if (speeds.length === 0) return; - const text = `Solid-copy Morse speed certificate${speeds.length > 1 ? 's' : ''} earned: ${speeds.join(', ')} — trained with CW Trainer. 🎧🔑`; +async function shareCertificates(matrix: SpeedCertificateMatrix): Promise { + const fullSet = matrix.fullAlphabetEarnedSpeeds.map((speed) => `${speed} WPM (full alphabet)`); + const summary = + fullSet.length > 0 + ? fullSet.join(', ') + : `${matrix.earnedCount} stage certificate${matrix.earnedCount > 1 ? 's' : ''}`; + const text = `Solid-copy Morse certificates earned: ${summary} — trained with CW Trainer. 🎧🔑`; try { if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { await navigator.share({ text }); diff --git a/src/components/ui/training/NextGoalCard.tsx b/src/components/ui/training/NextGoalCard.tsx index 0b157b1..329d5ca 100644 --- a/src/components/ui/training/NextGoalCard.tsx +++ b/src/components/ui/training/NextGoalCard.tsx @@ -2,7 +2,14 @@ import React, { useMemo } from 'react'; -import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum'; +import { + attemptedStageForLevel, + buildTeachingPlan, + CERT_MIN_ACCURACY, + evaluateSessionSpeedRun, + evaluateSpeedCertificateMatrix, + evaluateTeachingPlan, +} from '@/lib/curriculum'; import type { SessionResult, TrainingSettings } from '@/types'; export interface NextGoalCardProps { @@ -56,7 +63,65 @@ export function NextGoalCard({ sessions, settings, lastScore }: NextGoalCardProp return `Next up: reach level ${active.stage.levelEnd} to work on Stage ${stageNumber}.`; }, [sessions, settings.kochLevel, settings.customSequence]); - if (!bestLine && !goalLine) return null; + const certLine = useMemo(() => { + const groupSessions = sessions.filter(isGroupSession); + const latest = groupSessions[groupSessions.length - 1]; + if (!latest) return null; + const sequence = + Array.isArray(settings.customSequence) && settings.customSequence.length > 0 + ? settings.customSequence + : undefined; + const plan = sequence ? buildTeachingPlan(sequence) : buildTeachingPlan(); + const stage = attemptedStageForLevel(plan, latest.kochLevel); + if (!stage) return null; + const matrix = evaluateSpeedCertificateMatrix(sessions, plan); + const row = matrix.rows[stage.index]; + if (!row) return null; + const stageLabel = + stage.index === matrix.rows.length - 1 ? 'full-alphabet' : `Stage ${stage.index + 1}`; + + // Newly earned this session beats any near-miss message. + const earnedNow = row.cells.find( + (cell) => cell.earned && cell.earnedSessionTimestamp === latest.timestamp, + ); + if (earnedNow) { + return { + emoji: '🏅', + text: `Certificate earned: ${stageLabel} solid copy at ${earnedNow.speed} WPM!`, + }; + } + + // Nearest miss among unearned speeds: the run with the most progress this session. + let best: { speed: number; run: ReturnType } | null = null; + for (const cell of row.cells) { + if (cell.earned) continue; + const run = evaluateSessionSpeedRun(latest, cell.speed); + if (run.totalChars === 0) continue; + if (!best || run.correctChars > best.run.correctChars) { + best = { speed: cell.speed, run }; + } + } + if (!best || best.run.correctChars === 0) return null; + const { run, speed } = best; + if (run.correctChars < run.totalChars || run.correctChars < 100) { + const short = Math.max(0, 100 - run.correctChars); + if (short > 0) { + return { + emoji: '📜', + text: `${stageLabel} certificate at ${speed} WPM: ${run.correctChars}/100 correct at speed — ${short} short. One more run?`, + }; + } + } + if (run.accuracy < CERT_MIN_ACCURACY) { + return { + emoji: '📜', + text: `${stageLabel} certificate at ${speed} WPM: enough characters, but ${Math.round(run.accuracy * 100)}% at-speed accuracy — need ${Math.round(CERT_MIN_ACCURACY * 100)}%.`, + }; + } + return null; + }, [sessions, settings.customSequence]); + + if (!bestLine && !goalLine && !certLine) return null; return (
@@ -66,6 +131,12 @@ export function NextGoalCard({ sessions, settings, lastScore }: NextGoalCardProp {bestLine.text}

) : null} + {certLine ? ( +

+ {certLine.emoji} + {certLine.text} +

+ ) : null} {goalLine ? (

🎯 diff --git a/src/hooks/useTrainingAudio.ts b/src/hooks/useTrainingAudio.ts index ae0d5c8..82f206c 100644 --- a/src/hooks/useTrainingAudio.ts +++ b/src/hooks/useTrainingAudio.ts @@ -14,7 +14,13 @@ import { pickTrainingToneHz } from '@/lib/trainingSessionPlayback'; import type { TrainingSettings } from '@/types'; export type TrainingAudioPlaybackResult = - | { readonly status: 'played'; readonly durationSec: number } + | { + readonly status: 'played'; + readonly durationSec: number; + /** Character speed this group was actually played at (ranges sample per group). */ + readonly charWpm: number; + readonly effectiveWpm: number; + } | { readonly status: 'skippedBecauseAborted' } | { readonly status: 'suspended' } | { readonly status: 'failed'; readonly message: string }; @@ -125,7 +131,7 @@ export function useTrainingAudio( resumeAudioContextFromUserGesture(ctx); } const bandConditions = ensureBandConditions(ctx); - const { durationSec, stop } = await playMorseCodeControlled( + const { durationSec, stop, resolvedCharWpm, resolvedEffectiveWpm } = await playMorseCodeControlled( ctx, text, { @@ -151,7 +157,12 @@ export function useTrainingAudio( }, ); currentStopRef.current = stop; - return { status: 'played', durationSec }; + return { + status: 'played', + durationSec, + charWpm: resolvedCharWpm, + effectiveWpm: resolvedEffectiveWpm, + }; } catch (error) { console.error('[TrainingAudio] Playback error:', error); return { diff --git a/src/hooks/useTrainingSession.ts b/src/hooks/useTrainingSession.ts index 365db71..c2144a1 100644 --- a/src/hooks/useTrainingSession.ts +++ b/src/hooks/useTrainingSession.ts @@ -175,6 +175,7 @@ export function useTrainingSession({ const groupStartAtRef = useRef([]); const groupEndAtRef = useRef([]); const groupAnswerAtRef = useRef([]); + const groupCharWpmRef = useRef([]); const groupCompletionResolversRef = useRef< Record void) | null> >({}); @@ -386,7 +387,14 @@ export function useTrainingSession({ const delta = Math.max(0, ansAt - endAt); const sent = entry.sent; const perChar = sent.length > 0 ? Math.round(delta / sent.length) : 0; - return { timeToCompleteMs: Number.isFinite(delta) ? delta : 0, perCharMs: perChar }; + const playedCharWpm = groupCharWpmRef.current[idx]; + return { + timeToCompleteMs: Number.isFinite(delta) ? delta : 0, + perCharMs: perChar, + ...(typeof playedCharWpm === 'number' && playedCharWpm >= 1 + ? { charWpm: Math.round(playedCharWpm * 10) / 10 } + : {}), + }; }); const result = buildSessionResult({ @@ -569,6 +577,7 @@ export function useTrainingSession({ groupStartAtRef.current = []; groupEndAtRef.current = []; groupAnswerAtRef.current = []; + groupCharWpmRef.current = []; charSamplingStateRef.current = null; const initialSampling = generateTrainingGroup(settings, historicalSessions); @@ -624,6 +633,9 @@ export function useTrainingSession({ showToast({ message, type: 'error' }); break; } + if (playback.status === 'played') { + groupCharWpmRef.current[i] = playback.charWpm; + } const durationSec = playback.status === 'played' ? playback.durationSec : 0; if (playback.status === 'played' && durationSec > 0) { await audio.sleepCancelable( diff --git a/src/lib/achievements/badges.ts b/src/lib/achievements/badges.ts index fb5d258..3d9fae9 100644 --- a/src/lib/achievements/badges.ts +++ b/src/lib/achievements/badges.ts @@ -298,6 +298,33 @@ export const ACHIEVEMENT_BADGES = [ rarity: 'epic', category: 'mastery', }, + { + id: 'solid-copy-5wpm', + title: 'Novice Copy (5 WPM)', + description: 'Solid copy over the full alphabet at 5 WPM — the historic Novice test.', + criteria: 'In one full-alphabet session, copy 100+ characters correctly at ≥5 WPM with ≥90% accuracy.', + tier: 'silver', + rarity: 'uncommon', + category: 'performance', + }, + { + id: 'solid-copy-13wpm', + title: 'General Copy (13 WPM)', + description: 'Solid copy over the full alphabet at 13 WPM — the historic General test.', + criteria: 'In one full-alphabet session, copy 100+ characters correctly at ≥13 WPM with ≥90% accuracy.', + tier: 'gold', + rarity: 'rare', + category: 'performance', + }, + { + id: 'solid-copy-20wpm', + title: 'Extra Copy (20 WPM)', + description: 'Solid copy over the full alphabet at 20 WPM — the historic Amateur Extra test.', + criteria: 'In one full-alphabet session, copy 100+ characters correctly at ≥20 WPM with ≥90% accuracy.', + tier: 'diamond', + rarity: 'epic', + category: 'performance', + }, ] as const satisfies readonly AchievementBadgeDefinition[]; export const ACHIEVEMENT_BADGE_BY_ID: Readonly> = diff --git a/src/lib/achievements/evaluateAchievements.ts b/src/lib/achievements/evaluateAchievements.ts index 3fda2b1..4296d95 100644 --- a/src/lib/achievements/evaluateAchievements.ts +++ b/src/lib/achievements/evaluateAchievements.ts @@ -1,3 +1,4 @@ +import { evaluateSessionSpeedRun } from '@/lib/curriculum'; import { localDateForTimestamp } from '@/lib/localDate'; import type { SessionResult } from '@/types'; @@ -347,6 +348,26 @@ const buildUnlockCandidates = ({ candidates.push({ id: 'koch-graduate', session: kochGraduate }); } + // Historic code-test trophies: solid copy over the FULL alphabet at exam + // speed, earned within a single session (see curriculum/speedCertificates). + const SOLID_COPY_TROPHIES: ReadonlyArray<{ id: AchievementId; wpm: number }> = [ + { id: 'solid-copy-5wpm', wpm: 5 }, + { id: 'solid-copy-13wpm', wpm: 13 }, + { id: 'solid-copy-20wpm', wpm: 20 }, + ]; + SOLID_COPY_TROPHIES.forEach(({ id, wpm }) => { + const earningSession = findFirst( + groupSessions, + (session) => + session.kochLevel !== undefined && + session.kochLevel >= KOCH_GRADUATE_LEVEL && + evaluateSessionSpeedRun(session, wpm).achieved, + ); + if (earningSession) { + candidates.push({ id, session: earningSession }); + } + }); + return candidates; }; diff --git a/src/lib/achievements/types.ts b/src/lib/achievements/types.ts index 4bd7d7a..9b71653 100644 --- a/src/lib/achievements/types.ts +++ b/src/lib/achievements/types.ts @@ -39,7 +39,10 @@ export type AchievementId = | 'score-2500' | 'score-5000' | 'megawatt' - | 'koch-graduate'; + | 'koch-graduate' + | 'solid-copy-5wpm' + | 'solid-copy-13wpm' + | 'solid-copy-20wpm'; export type AchievementBadgeDefinition = { readonly id: AchievementId; diff --git a/src/lib/curriculum/index.ts b/src/lib/curriculum/index.ts index fe33a6c..a49cfca 100644 --- a/src/lib/curriculum/index.ts +++ b/src/lib/curriculum/index.ts @@ -1,7 +1,5 @@ export { buildTeachingPlan, - CERTIFICATE_MIN_CHARS, - CERTIFICATE_SESSIONS_TARGET, CERTIFICATE_SPEEDS_WPM, COPY_TEST_MIN_CHARS, QUALITY_ACCURACY, @@ -10,10 +8,22 @@ export { export type { CurriculumStage } from './teachingPlan'; export { evaluateTeachingPlan } from './progress'; export type { - SpeedCertificateProgress, StageGoalId, StageGoalProgress, StageProgress, StageStatus, TeachingPlanProgress, } from './progress'; +export { + attemptedStageForLevel, + CERT_MIN_ACCURACY, + CERT_MIN_CORRECT_CHARS, + evaluateSessionSpeedRun, + evaluateSpeedCertificateMatrix, +} from './speedCertificates'; +export type { + CertificateCell, + CertificateStageRow, + SessionSpeedRun, + SpeedCertificateMatrix, +} from './speedCertificates'; diff --git a/src/lib/curriculum/progress.ts b/src/lib/curriculum/progress.ts index 531bff3..d7dc49c 100644 --- a/src/lib/curriculum/progress.ts +++ b/src/lib/curriculum/progress.ts @@ -1,9 +1,10 @@ import type { SessionResult } from '@/types'; import { - CERTIFICATE_MIN_CHARS, - CERTIFICATE_SESSIONS_TARGET, - CERTIFICATE_SPEEDS_WPM, + evaluateSpeedCertificateMatrix, + type SpeedCertificateMatrix, +} from './speedCertificates'; +import { COPY_TEST_MIN_CHARS, QUALITY_ACCURACY, QUALITY_SESSIONS_TARGET, @@ -32,21 +33,12 @@ export interface StageProgress { readonly bestCopyTestAccuracy?: number; } -export interface SpeedCertificateProgress { - readonly wpm: number; - readonly earned: boolean; - /** Qualifying solid-copy sessions counted toward this certificate. */ - readonly current: number; - readonly target: number; - /** Timestamp of the session that completed the threshold, when earned. */ - readonly sessionTimestamp?: number; -} - export interface TeachingPlanProgress { readonly stages: readonly StageProgress[]; /** Index of the first incomplete stage; equals stages.length when the plan is finished. */ readonly activeStageIndex: number; - readonly certificates: readonly SpeedCertificateProgress[]; + /** Per-(stage × speed) certificate matrix; earned by single-session at-speed runs. */ + readonly certificates: SpeedCertificateMatrix; /** Total stages completed. */ readonly completedStageCount: number; } @@ -146,28 +138,7 @@ export function evaluateTeachingPlan( } } - const certificates: SpeedCertificateProgress[] = CERTIFICATE_SPEEDS_WPM.map((wpm) => { - const qualifying = sessions - .filter( - (session) => - isGroupSession(session) && - typeof session.charWpm === 'number' && - session.charWpm >= wpm && - session.totalChars >= CERTIFICATE_MIN_CHARS && - session.accuracy >= QUALITY_ACCURACY, - ) - .slice() - .sort((a, b) => a.timestamp - b.timestamp); - const earned = qualifying.length >= CERTIFICATE_SESSIONS_TARGET; - const earningSession = earned ? qualifying[CERTIFICATE_SESSIONS_TARGET - 1] : undefined; - return { - wpm, - earned, - current: Math.min(qualifying.length, CERTIFICATE_SESSIONS_TARGET), - target: CERTIFICATE_SESSIONS_TARGET, - ...(earningSession !== undefined ? { sessionTimestamp: earningSession.timestamp } : {}), - }; - }); + const certificates = evaluateSpeedCertificateMatrix(sessions, stages); return { stages: stageProgress, diff --git a/src/lib/curriculum/speedCertificates.ts b/src/lib/curriculum/speedCertificates.ts new file mode 100644 index 0000000..6c00e9a --- /dev/null +++ b/src/lib/curriculum/speedCertificates.ts @@ -0,0 +1,175 @@ +import { alignGroup } from '@/lib/groupAlignment'; +import type { SessionResult } from '@/types'; + +import { CERTIFICATE_SPEEDS_WPM, buildTeachingPlan, type CurriculumStage } from './teachingPlan'; + +/** + * Speed certificates, modelled on the historic FCC code exams but generalised + * to the Koch ladder. + * + * A certificate cell is (stage × speed). It is earned by a SINGLE SESSION — + * like the real exam, it's one sustained run, not an accumulation: at least + * {@link CERT_MIN_CORRECT_CHARS} correctly copied characters from groups that + * were actually PLAYED at or above the certificate speed, with at-speed + * accuracy of at least {@link CERT_MIN_ACCURACY}, in a session at or above the + * stage's exit level. With variable-speed settings only the groups that met + * the speed count — per-group speeds are recorded in `groupTimings[].charWpm`. + * + * The final stage covers the complete character sequence, so its cells are the + * true historic equivalents (5/13/20 WPM over the full alphabet); those also + * unlock trophy-case achievements. + */ + +export const CERT_MIN_CORRECT_CHARS = 100; +export const CERT_MIN_ACCURACY = 0.9; + +export interface SessionSpeedRun { + readonly speed: number; + /** Characters correctly copied from groups played at >= speed. */ + readonly correctChars: number; + /** Characters sent in groups played at >= speed. */ + readonly totalChars: number; + /** Accuracy among at-speed characters (0 when none). */ + readonly accuracy: number; + /** Whether this single session satisfies the certificate. */ + readonly achieved: boolean; +} + +const isGroupSession = (session: SessionResult): boolean => + session.mode === undefined || session.mode === 'group'; + +const isAlphabetSession = (session: SessionResult): boolean => + session.charSetMode === undefined || + session.charSetMode === 'koch' || + session.charSetMode === 'mixed'; + +/** + * Evaluate one session's copy run at a certificate speed. + * Groups without a recorded per-group speed fall back to the session-level + * charWpm snapshot (the range minimum) — conservative for legacy sessions. + */ +export function evaluateSessionSpeedRun(session: SessionResult, speed: number): SessionSpeedRun { + let correctChars = 0; + let totalChars = 0; + + session.groups.forEach((group, index) => { + const timing = session.groupTimings[index]; + const groupSpeed = + typeof timing?.charWpm === 'number' && timing.charWpm >= 1 + ? timing.charWpm + : session.charWpm; + if (typeof groupSpeed !== 'number' || groupSpeed < speed) return; + const alignment = alignGroup(group.sent, group.received); + alignment.forEach((pair) => { + if (pair.sentChar === null) return; // ignore insertions + totalChars += 1; + if (pair.match) correctChars += 1; + }); + }); + + const accuracy = totalChars > 0 ? correctChars / totalChars : 0; + return { + speed, + correctChars, + totalChars, + accuracy, + achieved: correctChars >= CERT_MIN_CORRECT_CHARS && accuracy >= CERT_MIN_ACCURACY, + }; +} + +export interface CertificateCell { + readonly speed: number; + readonly earned: boolean; + readonly earnedSessionTimestamp?: number; + /** Best single-session attempt so far (highest correct-at-speed count). */ + readonly bestCorrectChars: number; + readonly bestAccuracy: number; + readonly targetChars: number; +} + +export interface CertificateStageRow { + readonly stage: CurriculumStage; + readonly cells: readonly CertificateCell[]; +} + +export interface SpeedCertificateMatrix { + readonly rows: readonly CertificateStageRow[]; + readonly earnedCount: number; + readonly totalCells: number; + /** Earned cells of the final (full character set) stage — the historic certificates. */ + readonly fullAlphabetEarnedSpeeds: readonly number[]; +} + +/** The stage a session attempts: the highest stage whose exit level the session reaches. */ +export function attemptedStageForLevel( + stages: readonly CurriculumStage[], + kochLevel: number | undefined, +): CurriculumStage | null { + if (typeof kochLevel !== 'number') return null; + let attempted: CurriculumStage | null = null; + for (const stage of stages) { + if (kochLevel >= stage.levelEnd) attempted = stage; + else break; + } + return attempted; +} + +export function evaluateSpeedCertificateMatrix( + sessions: readonly SessionResult[], + stages: readonly CurriculumStage[] = buildTeachingPlan(), + speeds: readonly number[] = CERTIFICATE_SPEEDS_WPM, +): SpeedCertificateMatrix { + const eligible = sessions.filter( + (session) => isGroupSession(session) && isAlphabetSession(session), + ); + + const rows: CertificateStageRow[] = stages.map((stage) => { + const qualifying = eligible.filter( + (session) => + typeof session.kochLevel === 'number' && session.kochLevel >= stage.levelEnd, + ); + const cells: CertificateCell[] = speeds.map((speed) => { + let earned = false; + let earnedSessionTimestamp: number | undefined; + let bestCorrectChars = 0; + let bestAccuracy = 0; + qualifying.forEach((session) => { + const run = evaluateSessionSpeedRun(session, speed); + if (run.correctChars > bestCorrectChars || + (run.correctChars === bestCorrectChars && run.accuracy > bestAccuracy)) { + bestCorrectChars = run.correctChars; + bestAccuracy = run.accuracy; + } + if (run.achieved && !earned) { + earned = true; + earnedSessionTimestamp = session.timestamp; + } + }); + return { + speed, + earned, + ...(earnedSessionTimestamp !== undefined ? { earnedSessionTimestamp } : {}), + bestCorrectChars, + bestAccuracy, + targetChars: CERT_MIN_CORRECT_CHARS, + }; + }); + return { stage, cells }; + }); + + const earnedCount = rows.reduce( + (sum, row) => sum + row.cells.filter((cell) => cell.earned).length, + 0, + ); + const lastRow = rows[rows.length - 1]; + const fullAlphabetEarnedSpeeds = lastRow + ? lastRow.cells.filter((cell) => cell.earned).map((cell) => cell.speed) + : []; + + return { + rows, + earnedCount, + totalCells: rows.length * speeds.length, + fullAlphabetEarnedSpeeds, + }; +} diff --git a/src/lib/curriculum/teachingPlan.ts b/src/lib/curriculum/teachingPlan.ts index 83346f5..0668d4f 100644 --- a/src/lib/curriculum/teachingPlan.ts +++ b/src/lib/curriculum/teachingPlan.ts @@ -10,10 +10,9 @@ import { LCWO_SEQUENCE } from '@/lib/morseConstants'; * least {@link COPY_TEST_MIN_CHARS} characters at ≥{@link QUALITY_ACCURACY} * accuracy with the stage's alphabet. * - * Speed certificates are solid-copy milestones at the historic FCC exam - * speeds (5 / 13 / 20 WPM). They are independent of stage progress and are - * earned only after repeated quality sessions at or above the certificate - * speed — not a single lucky run. + * Speed certificates mirror the historic FCC licence exams (5 / 13 / 20 WPM + * received code): they are earned by a solid-copy session at or above the + * certificate speed, independent of stage progress. */ export interface CurriculumStage { @@ -36,13 +35,6 @@ export const QUALITY_ACCURACY = 0.9; export const QUALITY_SESSIONS_TARGET = 2; /** Minimum characters copied in one session for it to count as a copy test. */ export const COPY_TEST_MIN_CHARS = 100; -/** Minimum characters for a speed-certificate session (≈5 minutes of 5-char words at 5 WPM). */ -export const CERTIFICATE_MIN_CHARS = 125; -/** - * Qualifying solid-copy sessions required at/above a certificate speed before - * that certificate is earned. Keeps certificates from being one-session flukes. - */ -export const CERTIFICATE_SESSIONS_TARGET = 3; /** Historic FCC code-test speeds (Novice / General / Extra), in WPM. */ export const CERTIFICATE_SPEEDS_WPM: readonly number[] = [5, 13, 20]; diff --git a/src/lib/services/session.service.ts b/src/lib/services/session.service.ts index babbc71..2272a9a 100644 --- a/src/lib/services/session.service.ts +++ b/src/lib/services/session.service.ts @@ -65,6 +65,7 @@ export class SessionService { groupTimings: groupTimings.map(t => ({ timeToCompleteMs: t.timeToCompleteMs, ...(t.perCharMs !== undefined ? { perCharMs: t.perCharMs } : {}), + ...(t.charWpm !== undefined ? { charWpm: t.charWpm } : {}), })), ...(mode !== undefined ? { mode } : {}), ...(firestoreId !== undefined ? { firestoreId } : {}), @@ -166,6 +167,7 @@ export class SessionService { groupTimings: groupTimings.map(t => ({ timeToCompleteMs: t.timeToCompleteMs, ...(t.perCharMs !== undefined ? { perCharMs: t.perCharMs } : {}), + ...(t.charWpm !== undefined ? { charWpm: t.charWpm } : {}), })), ...(mode !== undefined ? { mode } : {}), ...(firestoreId !== undefined ? { firestoreId } : {}), diff --git a/src/lib/validators/session-result.schema.ts b/src/lib/validators/session-result.schema.ts index 83b2844..3e906cc 100644 --- a/src/lib/validators/session-result.schema.ts +++ b/src/lib/validators/session-result.schema.ts @@ -16,6 +16,7 @@ const sessionGroupSchema = z.object({ const sessionTimingSchema = z.object({ timeToCompleteMs: z.number().min(0), perCharMs: z.number().min(0).optional(), + charWpm: z.number().min(1).max(100).optional(), }); const sessionCharSetModeSchema = z.enum(['koch', 'digits', 'custom', 'mixed']); diff --git a/src/types/domain.ts b/src/types/domain.ts index cc3341a..83cad76 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -227,6 +227,8 @@ export interface SessionGroup { export interface SessionTiming { readonly timeToCompleteMs: number; readonly perCharMs?: number; + /** Character speed (WPM) this group was actually played at — ranges sample per group. */ + readonly charWpm?: number; } /**