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
76 changes: 76 additions & 0 deletions docs/TEACHING_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Structured Teaching Plan

CW Trainer already trains with the Koch method, but the level ladder alone gives
no sense of *syllabus*: what am I working toward, what counts as "done", and how
do I know I can actually copy code? The teaching plan answers those three
questions with **stages**, **goals**, and **copy tests**.

## Concepts

### Stages

The character sequence (LCWO order by default, custom sequences supported) is
chunked into stages of 5 Koch levels. Each stage lists the characters it
introduces and has an explicit exit level. Stages are defined in
`src/lib/curriculum/teachingPlan.ts` (`buildTeachingPlan`).

### Goals

A stage is complete when all of its goals are achieved
(`src/lib/curriculum/progress.ts`, `evaluateTeachingPlan`):

| Goal | Requirement |
|---|---|
| Reach level | Train at (or above) the stage's exit level |
| Quality sessions | 2 sessions at ≥90% accuracy at/above the exit level |
| Copy test | One session of 100+ characters at ≥90% accuracy at/above the exit level |

Progress is evaluated from **saved session history** — your regular training
sessions *are* the tests. There is no separate exam mode to schedule: a long,
accurate session at the right level automatically counts as a passed copy test.
Sessions at higher levels retroactively complete lower stages; the plan
recognises demonstrated skill and never demands re-grinding earlier material.

Only group-mode sessions with a recorded Koch level count (echo, chase, ICR and
digits-only sessions train different skills and are excluded).

### Copy tests

The copy test is the digital analogue of the classic "solid copy" exam:
sustained accurate reception, not a lucky short run. 100 characters at ≥90%
demands roughly 4–5 minutes of continuous copying, which is where real
reading-by-rhythm skill shows.

### Speed certificates

Independent of stage progress, three certificates mirror the historic FCC
licence code tests — **5, 13 and 20 WPM** (Novice / General / Amateur Extra).
A certificate is earned by one session of 125+ characters at ≥90% accuracy with
a character speed at or above the certificate speed.

Sessions now snapshot their character/effective WPM (`charWpm`,
`effectiveWpm` on `SessionResult`, captured from the settings' lower range
bound), so certificates are only awarded from sessions where the speed is
actually known. Historical sessions without a speed snapshot never earn
certificates — the plan does not guess.

## 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 certificate row with earned/unearned state.

## Future extensions

- **Dedicated exam mode**: a locked-settings session (fixed length, no retries,
prescribed speed) started from the panel, marked `isTest` in the session
record. The evaluation logic in `progress.ts` already works on plain
sessions, so an exam mode only needs to *create* qualifying sessions.
- **Per-stage speed targets**: raise effective WPM requirements per stage
(e.g. 20/5 Farnsworth early, 20/13 later) now that speed is recorded.
- **Certificate share cards**: generate a shareable badge image when a
certificate is earned.
- **Plan-aware suggestions**: when a goal is nearly met ("one more 90%
session"), surface it on the home screen as the day's objective.
24 changes: 22 additions & 2 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,32 @@ rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {
// Leaderboard entries are globally readable, so writes must be validated:
// without field checks any signed-in user could publish arbitrary scores.
// This bounds the damage; true trust requires server-side scoring.
function isValidLeaderboardEntry() {
let d = request.resource.data;
return d.uid == request.auth.uid
&& d.publicId is number
&& d.timestamp is number && d.timestamp > 0
&& d.score is number && d.score >= 0 && d.score <= 50000
&& d.accuracy is number && d.accuracy >= 0 && d.accuracy <= 1
&& d.totalChars is number && d.totalChars >= 1 && d.totalChars <= 20000
&& d.avgResponseMs is number && d.avgResponseMs >= 0
&& (!('callSign' in d) || d.callSign == null || (d.callSign is string && d.callSign.size() <= 20));
}

// Allow reading leaderboard entries from any user for global leaderboard
// This enables collectionGroup queries on 'leaderboard' subcollection
match /users/{userId}/leaderboard/{document} {
allow read: if request.auth != null;
// Only the owner can write their own leaderboard entries
allow write: if request.auth != null && request.auth.uid == userId;
// Only the owner can create entries, and only well-formed ones
allow create: if request.auth != null && request.auth.uid == userId
&& isValidLeaderboardEntry();
// Existing entries are immutable except for call-sign refresh
allow update: if request.auth != null && request.auth.uid == userId
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['callSign']);
allow delete: if request.auth != null && request.auth.uid == userId;
}

// Allow reading call-sign from any user's profile for leaderboard display
Expand Down
23 changes: 23 additions & 0 deletions lib/localDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Local calendar date helpers.
*
* Session dates must reflect the user's local calendar day, not UTC.
* Using `toISOString()` stamps sessions with the UTC date, which credits
* evening practice (for users west of UTC) to the wrong day and breaks
* daily streaks even when the user practiced on consecutive local days.
*/

/** Format a Date as YYYY-MM-DD in the local timezone. */
export function formatLocalDate(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

/** Local calendar date string for a millisecond timestamp. */
export function localDateForTimestamp(ts: number): string {
const d = new Date(ts);
if (!Number.isFinite(ts) || Number.isNaN(d.getTime())) return '1970-01-01';
return formatLocalDate(d);
}
8 changes: 6 additions & 2 deletions lib/morseAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ function resolveCharWpm(settings: AudioSettings): number {

/**
* Resolve the effective WPM from settings, supporting both fixed and range values.
* Farnsworth spacing stretches gaps between characters, so the effective speed can
* never exceed the character speed. Without this clamp, effective > 3× character WPM
* makes the inter-character gap (3 effective dits) shorter than the element gap
* (1 character dit), driving the scheduling cursor backwards and overlapping tones.
*/
function resolveEffectiveWpm(settings: AudioSettings, charWpm: number): number {
// If range is specified, use it
if (settings.effectiveWpmMin !== undefined && settings.effectiveWpmMax !== undefined) {
return Math.max(1, pickRandomInRange(settings.effectiveWpmMin, settings.effectiveWpmMax));
return Math.min(charWpm, Math.max(1, pickRandomInRange(settings.effectiveWpmMin, settings.effectiveWpmMax)));
}
// Fall back to fixed effectiveWpm or charWpm
return Math.max(1, settings.effectiveWpm ?? charWpm);
return Math.min(charWpm, Math.max(1, settings.effectiveWpm ?? charWpm));
}

/**
Expand Down
Loading
Loading