diff --git a/docs/TEACHING_PLAN.md b/docs/TEACHING_PLAN.md new file mode 100644 index 0000000..a1df8e4 --- /dev/null +++ b/docs/TEACHING_PLAN.md @@ -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. diff --git a/firestore.rules b/firestore.rules index df1f7cd..f718184 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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 diff --git a/lib/localDate.ts b/lib/localDate.ts new file mode 100644 index 0000000..41200c2 --- /dev/null +++ b/lib/localDate.ts @@ -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); +} diff --git a/lib/morseAudio.ts b/lib/morseAudio.ts index 5b8dae9..68471dd 100644 --- a/lib/morseAudio.ts +++ b/lib/morseAudio.ts @@ -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)); } /** diff --git a/lib/sessionPersistence.ts b/lib/sessionPersistence.ts index f92954a..ad760f6 100644 --- a/lib/sessionPersistence.ts +++ b/lib/sessionPersistence.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { getAuth } from 'firebase/auth'; import { + arrayUnion, collection, deleteDoc, doc, @@ -17,6 +18,7 @@ import { calculateGroupLetterAccuracy, calculateOverallCharacterAccuracy, } from '@/lib/groupAlignment'; +import { localDateForTimestamp } from '@/lib/localDate'; import { calculateAlphabetSize, calculateEffectiveAlphabetSize, @@ -109,7 +111,7 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi : sent.length > 0 && sent === received; return { sent, received, correct }; }); - const groupTimings = (() => { + 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), @@ -139,10 +141,7 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi ? Number(opts.docId) : Date.now(); const dateValue = rawObj['date']; - const date: string = - typeof dateValue === 'string' - ? dateValue - : (new Date(ts).toISOString().split('T')[0] ?? '1970-01-01'); + const date: string = typeof dateValue === 'string' ? dateValue : localDateForTimestamp(ts); const startedAt = typeof rawObj['startedAt'] === 'number' ? (rawObj['startedAt'] as number) : ts; const finishedAt = typeof rawObj['finishedAt'] === 'number' ? (rawObj['finishedAt'] as number) : ts; @@ -164,7 +163,12 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi isFinite(rawObj['avgResponseMs'] as number) && (rawObj['avgResponseMs'] as number) > 0 ? Number(rawObj['avgResponseMs']) - : computeAverageResponseMs(groupTimings); + : // Prefer perCharMs like buildSessionResult so recomputed scores match originals + computeAverageResponseMs( + groupTimings.map((t) => ({ + timeToCompleteMs: typeof t.perCharMs === 'number' ? t.perCharMs : t.timeToCompleteMs, + })), + ); const score = typeof rawObj['score'] === 'number' && isFinite(rawObj['score'] as number) && @@ -193,6 +197,18 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi typeof rawObj['digitsLevel'] === 'number' && Number.isFinite(rawObj['digitsLevel']) ? Math.floor(rawObj['digitsLevel'] as number) : undefined; + const charWpm = + typeof rawObj['charWpm'] === 'number' && + Number.isFinite(rawObj['charWpm']) && + (rawObj['charWpm'] as number) >= 1 + ? Number(rawObj['charWpm']) + : undefined; + const effectiveWpm = + typeof rawObj['effectiveWpm'] === 'number' && + Number.isFinite(rawObj['effectiveWpm']) && + (rawObj['effectiveWpm'] as number) >= 1 + ? Number(rawObj['effectiveWpm']) + : undefined; const result: SessionResult = { date, @@ -215,10 +231,58 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi ...(charSetMode !== undefined ? { charSetMode } : {}), ...(kochLevel !== undefined ? { kochLevel } : {}), ...(digitsLevel !== undefined ? { digitsLevel } : {}), + ...(charWpm !== undefined ? { charWpm } : {}), + ...(effectiveWpm !== undefined ? { effectiveWpm } : {}), }; return result; }; +/** + * Cloud tombstones for deleted sessions. + * + * Deletions must be visible to every device: without them, another device that + * still holds the deleted session in localStorage treats it as "local-only" on + * its next load and re-uploads it, resurrecting data the user deleted. + */ +async function recordDeletionTombstone( + services: FirebaseServicesLite, + user: { uid?: string } | null, + timestamp: number, +): Promise { + if (!(services && user && user.uid)) return; + try { + await setDoc( + doc(services.db, 'users', user.uid, 'meta', 'deletedSessions'), + { timestamps: arrayUnion(timestamp), updatedAt: Date.now() }, + { merge: true }, + ); + } catch (e) { + console.warn('Failed to record deletion tombstone; deletion may resurrect on other devices.', e); + } +} + +async function readDeletionTombstones( + services: FirebaseServicesLite, + user: { uid?: string } | null, +): Promise> { + const tombstones = new Set(); + if (!(services && user && user.uid)) return tombstones; + try { + const snap = await getDoc(doc(services.db, 'users', user.uid, 'meta', 'deletedSessions')); + if (snap.exists()) { + const data = snap.data() as any; + if (Array.isArray(data?.timestamps)) { + data.timestamps.forEach((t: unknown) => { + if (typeof t === 'number' && Number.isFinite(t)) tombstones.add(t); + }); + } + } + } catch { + // Unavailable tombstones only mean a potential re-upload, never data loss. + } + return tombstones; +} + async function ensurePublicId( services: FirebaseServicesLite, user: { uid: string } | null, @@ -284,10 +348,61 @@ export async function setUserCallSign( } } +function buildLeaderboardPayload( + uid: string, + publicId: number | null, + callSign: string | null, + r: SessionResult, + now: number, +): Record { + const alphabetSize = + typeof r.alphabetSize === 'number' && r.alphabetSize > 0 + ? r.alphabetSize + : calculateAlphabetSize(r.groups || []); + const effectiveAlphabetSize = + typeof r.effectiveAlphabetSize === 'number' && r.effectiveAlphabetSize > 0 + ? r.effectiveAlphabetSize + : calculateEffectiveAlphabetSize(r.groups || [], { applyMillerMadow: true }); + const totalChars = + typeof r.totalChars === 'number' && r.totalChars > 0 + ? r.totalChars + : calculateTotalChars(r.groups || []); + const avgResponseMs = + typeof r.avgResponseMs === 'number' && isFinite(r.avgResponseMs) && r.avgResponseMs > 0 + ? r.avgResponseMs + : computeAverageResponseMs(r.groupTimings || []); + const score = + typeof r.score === 'number' && isFinite(r.score) && r.score > 0 + ? r.score + : computeSessionScore({ + effectiveAlphabetSize, + alphabetSize, + accuracy: r.accuracy || 0, + avgResponseMs, + totalChars, + }); + return { + uid, + publicId: publicId, + ...(callSign ? { callSign } : {}), + timestamp: r.timestamp, + date: r.date, + score, + accuracy: r.accuracy, + alphabetSize, + effectiveAlphabetSize, + totalChars, + avgResponseMs, + createdAt: now, + version: 1, + }; +} + async function writeLeaderboardForSessions( services: FirebaseServicesLite, user: { uid: string } | null, results: SessionResult[], + opts?: { skipExistenceCheck?: boolean }, ): Promise { if (!(services && user && user.uid)) return; const publicId = user?.uid ? await ensurePublicId(services, { uid: user.uid }) : null; @@ -297,81 +412,28 @@ async function writeLeaderboardForSessions( results.map(async (r) => { // Nest under user scope for rules friendliness; one doc per session timestamp const ref = doc(services.db, 'users', user.uid, 'leaderboard', String(r.timestamp)); - try { - const ex = await getDoc(ref as any); - if (ex.exists()) { - // Entry exists - only update callSign if it's missing or changed (keep rest immutable) - const existingData = ex.data() as any; - const existingCallSign = - typeof existingData?.callSign === 'string' && existingData.callSign.length > 0 - ? existingData.callSign - : null; - const newCallSign = callSign && callSign.length > 0 ? callSign : null; - if (newCallSign !== existingCallSign) { - // Update only the callSign field using merge - const updatePayload: any = {}; - if (newCallSign) { - updatePayload.callSign = newCallSign; - } else { - // Explicitly set to null to remove the field if call-sign was cleared - updatePayload.callSign = null; + // For freshly created sessions the doc cannot exist yet, so the existence + // read is skipped (it cost one read per historical session on every save). + if (!opts?.skipExistenceCheck) { + try { + const ex = await getDoc(ref as any); + if (ex.exists()) { + // Entry exists - only update callSign if it's missing or changed (keep rest immutable) + const existingData = ex.data() as any; + const existingCallSign = + typeof existingData?.callSign === 'string' && existingData.callSign.length > 0 + ? existingData.callSign + : null; + const newCallSign = callSign && callSign.length > 0 ? callSign : null; + if (newCallSign !== existingCallSign) { + await setDoc(ref, { callSign: newCallSign }, { merge: true }); } - await setDoc(ref, updatePayload, { merge: true }); - console.log( - '[Leaderboard] Updated callSign for entry:', - r.timestamp, - 'from', - existingCallSign, - 'to', - newCallSign, - ); + return; // Don't overwrite the rest of the entry } - return; // Don't overwrite the rest of the entry - } - } catch {} - // New entry - create it with all data - const alphabetSize = - typeof r.alphabetSize === 'number' && r.alphabetSize > 0 - ? r.alphabetSize - : calculateAlphabetSize(r.groups || []); - const effectiveAlphabetSize = - typeof r.effectiveAlphabetSize === 'number' && r.effectiveAlphabetSize > 0 - ? r.effectiveAlphabetSize - : calculateEffectiveAlphabetSize(r.groups || [], { applyMillerMadow: true }); - const totalChars = - typeof r.totalChars === 'number' && r.totalChars > 0 - ? r.totalChars - : calculateTotalChars(r.groups || []); - const avgResponseMs = - typeof r.avgResponseMs === 'number' && isFinite(r.avgResponseMs) && r.avgResponseMs > 0 - ? r.avgResponseMs - : computeAverageResponseMs(r.groupTimings || []); - const score = - typeof r.score === 'number' && isFinite(r.score) && r.score > 0 - ? r.score - : computeSessionScore({ - effectiveAlphabetSize, - alphabetSize, - accuracy: r.accuracy || 0, - avgResponseMs, - totalChars, - }); - const payload: any = { - uid: user.uid, - publicId: publicId, - ...(callSign ? { callSign } : {}), - timestamp: r.timestamp, - date: r.date, - score, - accuracy: r.accuracy, - alphabetSize, - effectiveAlphabetSize, - totalChars, - avgResponseMs, - createdAt: now, - version: 1, - }; - await setDoc(ref, payload, { merge: false }); + } catch {} + } + const payload = buildLeaderboardPayload(user.uid, publicId, callSign, r, now); + await setDoc(ref, payload as any, { merge: false }); }), ); } @@ -396,7 +458,7 @@ export async function loadSessions( if (cloudLoaded) { // Merge with any local-only entries let merged: SessionResult[] = cloudLoaded; - let hasLocalOnlyEntries = false; + const localOnly: SessionResult[] = []; try { const savedLocal = localStorage.getItem(localKeyForResults(user)); if (savedLocal) { @@ -406,27 +468,29 @@ export async function loadSessions( merged.forEach((s) => { byTs[s.timestamp] = s; }); - localNorm.forEach((s) => { - if (!byTs[s.timestamp]) { + const candidates = localNorm.filter((s) => !byTs[s.timestamp]); + // Local entries missing from the cloud are either unsynced new sessions + // or sessions deleted from another device — tombstones tell them apart. + const tombstones = + candidates.length > 0 ? await readDeletionTombstones(services, user) : new Set(); + candidates.forEach((s) => { + if (!tombstones.has(s.timestamp)) { byTs[s.timestamp] = s; - hasLocalOnlyEntries = true; // Found local-only entry that needs syncing + localOnly.push(s); // Found local-only entry that needs syncing } }); merged = Object.values(byTs).sort((a, b) => a.timestamp - b.timestamp); } } catch {} - // Only save to Firestore if there are local-only entries to sync, or if we need to update localStorage - // This avoids unnecessary writes that can hit quota limits - if (hasLocalOnlyEntries) { + // Only push the local-only entries to Firestore (never rewrite the whole + // history here — that caused unnecessary writes that hit quota limits). + if (localOnly.length > 0) { try { - await saveSessions(services, user, merged); // sync local-only entries to cloud + await saveSessionsSubset(services, user, localOnly, merged); } catch (e) { - // If save fails (e.g., quota exceeded), still update localStorage and continue + // Local copy is already written by saveSessionsSubset; retry later. console.warn('Failed to sync local sessions to Firestore; will retry later.', e); - try { - localStorage.setItem(localKeyForResults(user), JSON.stringify(merged)); - } catch {} } } else { // No local-only entries, just ensure localStorage is in sync @@ -451,6 +515,76 @@ export async function loadSessions( return []; } +async function writeStatsDocs( + services: NonNullable, + uid: string, + results: SessionResult[], +): Promise { + const daily = getDailyStats(results as any); + const letters = getLetterStats(results as any); + await Promise.all([ + setDoc(doc(services.db, 'users', uid, 'stats', 'daily'), { + items: daily, + updatedAt: Date.now(), + }), + setDoc(doc(services.db, 'users', uid, 'stats', 'letters'), { + items: letters, + updatedAt: Date.now(), + }), + ]); +} + +/** + * Persist only `subset` session docs to Firestore (plus aggregate stats computed + * from `allResults`). The full local copy is always written to localStorage. + * + * This is the hot path for finishing a session: it costs O(subset) Firestore + * operations instead of rewriting the entire history (which grew quadratically + * and exhausted free-tier quota for long-time users). + */ +export async function saveSessionsSubset( + services: FirebaseServicesLite, + user: { uid?: string; email?: string } | null, + subset: SessionResult[], + allResults: SessionResult[], +): Promise { + try { + localStorage.setItem(localKeyForResults(user), JSON.stringify(allResults)); + } catch {} + if (!(services && user && user.uid)) return; + if (subset.length === 0) return; + try { + await Promise.all( + subset.map((r) => { + const payload = { ...r } as any; + try { + delete payload.firestoreId; + } catch {} + return setDoc(doc(services.db, 'users', user.uid, 'sessions', String(r.timestamp)), payload); + }), + ); + await writeStatsDocs(services, user.uid, allResults); + // New sessions cannot have leaderboard entries yet — skip existence reads. + await writeLeaderboardForSessions(services, { uid: user.uid }, subset, { + skipExistenceCheck: true, + }); + } catch (e: any) { + const isQuotaError = + e?.code === 'resource-exhausted' || + e?.message?.includes('quota') || + e?.message?.includes('Quota'); + if (isQuotaError) { + console.warn('Firestore quota exceeded; local copy saved. Session queued for retry.', e); + } else { + console.warn('Failed to write to Firestore; local copy saved. Session queued for retry.', e); + } + // Rethrow so the caller queues the failed subset for retry. Stats docs are + // recomputed from the full list on the next successful save, so no + // full-sync escalation is needed here. + throw e; + } +} + export async function saveSessions( services: FirebaseServicesLite, user: { uid?: string; email?: string } | null, @@ -542,6 +676,7 @@ export async function deleteSessionPersisted( } await deleteDoc(doc(services.db, 'users', user.uid, 'sessions', docId)); + await recordDeletionTombstone(services, user, timestamp); // Delete matching leaderboard entry for consistency (per-session) try { await deleteDoc(doc(services.db, 'users', user.uid, 'leaderboard', String(timestamp))); @@ -608,6 +743,7 @@ export async function flushPendingOps( try { const docId = (ops.deletionIds && ops.deletionIds[String(ts)]) || String(ts); await deleteDoc(doc(services.db, 'users', user.uid, 'sessions', docId)); + await recordDeletionTombstone(services, user, ts); return { ts, ok: true }; } catch { return { ts, ok: false }; diff --git a/package-lock.json b/package-lock.json index 05b4d45..843025a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,11 +61,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -74,7 +76,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -82,19 +86,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -130,12 +136,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -145,12 +153,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -161,6 +171,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -169,6 +181,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -176,7 +190,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -184,25 +200,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -220,7 +240,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -228,7 +250,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -236,7 +260,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -244,23 +270,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -482,29 +512,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -512,12 +546,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1396,9 +1432,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1415,9 +1448,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1434,9 +1464,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1453,9 +1480,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1472,9 +1496,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1505,9 +1526,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1538,9 +1556,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1563,9 +1578,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1588,9 +1600,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1613,9 +1622,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1638,9 +1644,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1683,9 +1686,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1799,6 +1799,8 @@ }, "node_modules/@isaacs/cliui": { "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, "license": "ISC", "dependencies": { @@ -1815,6 +1817,8 @@ }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -1825,11 +1829,13 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -1874,7 +1880,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -2450,9 +2458,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2469,9 +2474,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2488,9 +2490,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2507,9 +2506,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2595,6 +2591,8 @@ }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", "optional": true, @@ -2774,7 +2772,9 @@ } }, "node_modules/@tootallnate/once": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true, "license": "MIT", "engines": { @@ -3227,9 +3227,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3416,9 +3416,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3433,9 +3430,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3450,9 +3444,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3467,9 +3458,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3484,9 +3472,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3501,9 +3486,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4115,7 +4097,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5039,6 +5023,8 @@ }, "node_modules/eastasianwidth": { "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, "license": "MIT" }, @@ -6115,15 +6101,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -6312,22 +6300,23 @@ } }, "node_modules/glob": { - "version": "10.3.10", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -6344,7 +6333,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -6480,7 +6471,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -7165,15 +7158,14 @@ } }, "node_modules/jackspeak": { - "version": "2.3.6", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -8054,8 +8046,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9177,6 +9181,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -10404,6 +10415,8 @@ }, "node_modules/string-width": { "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { @@ -10421,6 +10434,8 @@ "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -10434,11 +10449,15 @@ }, "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -10449,11 +10468,13 @@ } }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -10576,6 +10597,8 @@ "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { @@ -11210,7 +11233,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -11364,6 +11389,8 @@ }, "node_modules/wrap-ansi": { "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11381,6 +11408,8 @@ "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11397,11 +11426,15 @@ }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11415,6 +11448,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -11426,6 +11461,8 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -11436,11 +11473,13 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -11472,7 +11511,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -11513,6 +11554,8 @@ }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, diff --git a/src/__tests__/lib/achievements/evaluateAchievements.test.ts b/src/__tests__/lib/achievements/evaluateAchievements.test.ts index d96b22f..26c63bf 100644 --- a/src/__tests__/lib/achievements/evaluateAchievements.test.ts +++ b/src/__tests__/lib/achievements/evaluateAchievements.test.ts @@ -207,4 +207,22 @@ describe('achievement evaluation', () => { it('uses the diamond tier for the full-year streak trophy', () => { expect(ACHIEVEMENT_BADGE_BY_ID['full-year'].tier).toBe('diamond'); }); + + it('reports the current streak while it is still alive (today or yesterday)', () => { + const sessions = makeConsecutiveSessions(3); // 2026-01-01..03 + const nowOnLastDay = Date.parse('2026-01-03T12:00:00.000Z'); + const progress = calculateAchievementProgress(sessions, nowOnLastDay); + + expect(progress.currentStreakDays).toBe(3); + }); + + it('reports zero current streak once the run has lapsed', () => { + const sessions = makeConsecutiveSessions(5); // 2026-01-01..05 + const nowMuchLater = Date.parse('2026-03-01T12:00:00.000Z'); + const progress = calculateAchievementProgress(sessions, nowMuchLater); + + expect(progress.currentStreakDays).toBe(0); + // Historical records are unaffected by the lapse + expect(progress.longestStreakDays).toBe(5); + }); }); diff --git a/src/__tests__/lib/curriculum/teachingPlan.test.ts b/src/__tests__/lib/curriculum/teachingPlan.test.ts new file mode 100644 index 0000000..6b36633 --- /dev/null +++ b/src/__tests__/lib/curriculum/teachingPlan.test.ts @@ -0,0 +1,141 @@ +import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum'; +import { LCWO_SEQUENCE } from '@/lib/morseConstants'; +import type { SessionResult } from '@/types'; + +const makeSession = ({ + timestamp, + accuracy = 0.95, + totalChars = 120, + kochLevel, + charWpm, + mode, + charSetMode, +}: { + readonly timestamp: number; + readonly accuracy?: number; + readonly totalChars?: number; + readonly kochLevel?: number; + readonly charWpm?: number; + readonly mode?: 'group' | 'echo' | 'chase'; + readonly charSetMode?: 'koch' | 'digits' | 'custom' | 'mixed'; +}): SessionResult => ({ + date: '2026-07-01', + timestamp, + startedAt: timestamp - 60_000, + finishedAt: timestamp, + groups: [{ sent: 'KM', received: 'KM', correct: true }], + groupTimings: [{ timeToCompleteMs: 1500 }], + accuracy, + letterAccuracy: { K: { correct: 1, total: 1 }, M: { correct: 1, total: 1 } }, + alphabetSize: 2, + avgResponseMs: 1500, + totalChars, + effectiveAlphabetSize: 2, + score: 100, + ...(kochLevel !== undefined ? { kochLevel } : {}), + ...(charWpm !== undefined ? { charWpm } : {}), + ...(mode !== undefined ? { mode } : {}), + ...(charSetMode !== undefined ? { charSetMode } : {}), +}); + +describe('buildTeachingPlan', () => { + it('covers the whole LCWO sequence in level order', () => { + const stages = buildTeachingPlan(); + expect(stages.length).toBeGreaterThan(0); + const first = stages[0]!; + expect(first.levelStart).toBe(1); + // Level 1 unlocks two characters, so stage 1 owns K and M plus its own levels. + expect(first.characters.slice(0, 2)).toEqual(['K', 'M']); + const last = stages[stages.length - 1]!; + expect(last.levelEnd).toBe(LCWO_SEQUENCE.length - 1); + // Every character is introduced by exactly one stage. + const allChars = stages.flatMap((s) => [...s.characters]); + expect(allChars).toEqual([...LCWO_SEQUENCE]); + }); + + it('supports custom sequences and stage sizes', () => { + const stages = buildTeachingPlan(['A', 'B', 'C', 'D', 'E'], 2); + // 5 chars → levels 1..4 → stages [1-2], [3-4] + expect(stages.map((s) => [s.levelStart, s.levelEnd])).toEqual([ + [1, 2], + [3, 4], + ]); + expect(stages[0]!.characters).toEqual(['A', 'B', 'C']); + expect(stages[1]!.characters).toEqual(['D', 'E']); + }); +}); + +describe('evaluateTeachingPlan', () => { + it('marks the first stage active with no history', () => { + const progress = evaluateTeachingPlan([], 1); + expect(progress.activeStageIndex).toBe(0); + expect(progress.completedStageCount).toBe(0); + expect(progress.stages[0]!.status).toBe('active'); + expect(progress.stages[1]!.status).toBe('upcoming'); + }); + + it('completes a stage after quality sessions and a passed copy test', () => { + const sessions = [ + makeSession({ timestamp: 1, kochLevel: 5, accuracy: 0.95, totalChars: 120 }), + makeSession({ timestamp: 2, kochLevel: 5, accuracy: 0.92, totalChars: 120 }), + ]; + const progress = evaluateTeachingPlan(sessions, 5); + expect(progress.stages[0]!.status).toBe('complete'); + expect(progress.activeStageIndex).toBe(1); + expect(progress.stages[0]!.goals.every((g) => g.achieved)).toBe(true); + }); + + it('requires the copy-test volume, not just accuracy', () => { + const sessions = [ + makeSession({ timestamp: 1, kochLevel: 5, accuracy: 0.95, totalChars: 40 }), + makeSession({ timestamp: 2, kochLevel: 5, accuracy: 0.95, totalChars: 40 }), + ]; + const progress = evaluateTeachingPlan(sessions, 5); + const copyTest = progress.stages[0]!.goals.find((g) => g.id === 'copy-test')!; + expect(copyTest.achieved).toBe(false); + expect(progress.stages[0]!.status).toBe('active'); + }); + + it('retroactively completes earlier stages from high-level sessions', () => { + const sessions = [ + makeSession({ timestamp: 1, kochLevel: 12, accuracy: 0.95, totalChars: 150 }), + makeSession({ timestamp: 2, kochLevel: 12, accuracy: 0.93, totalChars: 150 }), + ]; + const progress = evaluateTeachingPlan(sessions, 12); + expect(progress.stages[0]!.status).toBe('complete'); + expect(progress.stages[1]!.status).toBe('complete'); + expect(progress.activeStageIndex).toBe(2); + }); + + it('ignores echo/chase and digits-only sessions', () => { + const sessions = [ + makeSession({ timestamp: 1, kochLevel: 5, mode: 'echo' }), + makeSession({ timestamp: 2, kochLevel: 5, mode: 'chase' }), + makeSession({ timestamp: 3, kochLevel: 5, charSetMode: 'digits' }), + ]; + const progress = evaluateTeachingPlan(sessions, 1); + const quality = progress.stages[0]!.goals.find((g) => g.id === 'quality-sessions')!; + expect(quality.current).toBe(0); + }); + + it('awards speed certificates from sessions with a recorded character speed', () => { + const sessions = [ + makeSession({ timestamp: 1, kochLevel: 3, charWpm: 15, accuracy: 0.94, totalChars: 130 }), + ]; + const progress = evaluateTeachingPlan(sessions, 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(1); + }); + + 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)).toBe(true); + }); +}); diff --git a/src/__tests__/services/session.service.test.ts b/src/__tests__/services/session.service.test.ts index 7069477..a657bd5 100644 --- a/src/__tests__/services/session.service.test.ts +++ b/src/__tests__/services/session.service.test.ts @@ -19,6 +19,17 @@ class InMemorySessionRepository implements SessionRepository { this.sessions = [...sessions]; } + async saveSubset( + _context: SessionRepositoryContext, + subset: readonly SessionResult[], + allSessions: readonly SessionResult[], + ): Promise { + // The in-memory store keeps the full list; subset semantics only matter + // for how many backend writes the real repository performs. + void subset; + this.sessions = [...allSessions]; + } + async deleteByTimestamp( _context: SessionRepositoryContext, timestamp: number, diff --git a/src/components/features/stats/Leaderboard.tsx b/src/components/features/stats/Leaderboard.tsx index dc58730..f58dbdd 100644 --- a/src/components/features/stats/Leaderboard.tsx +++ b/src/components/features/stats/Leaderboard.tsx @@ -72,6 +72,10 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E try { const rows: LeaderboardEntry[] = []; const limitN = Math.max(1, Math.min(100, limitCount)); + // Entries are one-per-session, but the board shows one row per operator. + // Over-fetch so that after per-user dedupe we can still fill limitN rows + // even when one prolific user holds many of the top session scores. + const fetchLimit = Math.min(200, limitN * 5); let lastErrorCode: string | null = null; const attempt = async (makeQuery: () => ReturnType): Promise => { @@ -116,6 +120,29 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E tempEntries.push({ data, entry }); }); + // One row per operator: keep each user's best-scoring session so a + // single prolific user cannot fill the whole board. + const bestByUser = new Map }>(); + tempEntries.forEach((item) => { + const key = + typeof item.data.uid === 'string' && item.data.uid.length > 0 + ? item.data.uid + : `pid:${item.data.publicId}`; + const current = bestByUser.get(key); + if (!current || (item.entry.score ?? 0) > (current.entry.score ?? 0)) { + bestByUser.set(key, item); + } + }); + tempEntries.length = 0; + [...bestByUser.values()] + .sort((a, b) => (b.entry.score ?? 0) - (a.entry.score ?? 0)) + .slice(0, limitN) + .forEach((item) => tempEntries.push(item)); + uidSet.clear(); + tempEntries.forEach(({ data }) => { + if (data.uid && typeof data.uid === 'string') uidSet.add(data.uid); + }); + // Look up call-signs from user profiles (current/latest call-sign) const callSignMap = new Map(); if (uidSet.size > 0 && services.db) { @@ -178,7 +205,7 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E query( collectionGroup(services.db, 'leaderboard'), orderBy('score', 'desc'), - limit(limitN), + limit(fetchLimit), ), ); } catch (_error) { @@ -192,7 +219,7 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E query( collection(services.db, 'leaderboard'), orderBy('score', 'desc'), - limit(limitN), + limit(fetchLimit), ), ); } catch (_error) { @@ -208,7 +235,7 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E query( collection(services.db, 'users', uid, 'leaderboard'), orderBy('score', 'desc'), - limit(limitN), + limit(fetchLimit), ), ); } catch (_error) { diff --git a/src/components/features/training/TeachingPlanPanel.tsx b/src/components/features/training/TeachingPlanPanel.tsx new file mode 100644 index 0000000..045223a --- /dev/null +++ b/src/components/features/training/TeachingPlanPanel.tsx @@ -0,0 +1,180 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; + +import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum'; +import type { StageProgress } from '@/lib/curriculum'; +import type { SessionResult, TrainingSettings } from '@/types'; + +export interface TeachingPlanPanelProps { + readonly sessions: readonly SessionResult[]; + readonly settings: Pick; +} + +function GoalRow({ label, achieved, current, target }: { + readonly label: string; + readonly achieved: boolean; + readonly current: number; + readonly target: number; +}): JSX.Element { + return ( +
  • + + {achieved ? '✔' : '○'} + + + {label} + + {target > 1 ? ( + + {current}/{target} + + ) : null} +
  • + ); +} + +function StageCard({ progress, expanded }: { + readonly progress: StageProgress; + readonly expanded: boolean; +}): JSX.Element { + const { stage, status, goals, bestCopyTestAccuracy } = progress; + const achievedCount = goals.filter((g) => g.achieved).length; + const border = + status === 'complete' + ? 'border-emerald-200 bg-emerald-50/50' + : status === 'active' + ? 'border-blue-200 bg-blue-50/50' + : 'border-slate-200 bg-white'; + + return ( +
    +
    + + {status === 'complete' ? '🏁' : status === 'active' ? '📍' : '🔒'} + +

    {stage.title}

    + + {achievedCount}/{goals.length} + +
    + {expanded ? ( +
      + {goals.map((goal) => ( + + ))} + {bestCopyTestAccuracy !== undefined ? ( +
    • + Best copy-test accuracy: {Math.round(bestCopyTestAccuracy * 100)}% +
    • + ) : null} +
    + ) : null} +
    + ); +} + +/** + * Structured teaching plan: staged goals over the Koch sequence with copy + * tests, plus historic-exam-style speed certificates. All progress is derived + * from saved session history — regular training sessions are the tests. + */ +export function TeachingPlanPanel({ sessions, settings }: TeachingPlanPanelProps): JSX.Element { + const [showAll, setShowAll] = useState(false); + + const plan = useMemo(() => { + const sequence = + Array.isArray(settings.customSequence) && settings.customSequence.length > 0 + ? settings.customSequence + : undefined; + return sequence ? buildTeachingPlan(sequence) : buildTeachingPlan(); + }, [settings.customSequence]); + + const progress = useMemo( + () => evaluateTeachingPlan(sessions, settings.kochLevel, plan), + [sessions, settings.kochLevel, plan], + ); + + const activeStage = progress.stages[progress.activeStageIndex]; + const visibleStages = showAll + ? progress.stages + : progress.stages.filter( + (s, i) => s.status === 'active' || i === progress.activeStageIndex - 1, + ); + + return ( +
    +
    +

    + 🎓 Teaching plan +

    + + {progress.completedStageCount}/{progress.stages.length} stages complete + + +
    + +
    +
    +
    + +
    + {visibleStages.map((stageProgress) => ( + + ))} + {activeStage === undefined ? ( +

    + Plan complete — every stage passed. 🎉 +

    + ) : null} +
    + +
    +

    + Copy certificates +

    + {progress.certificates.map((cert) => ( + + {cert.earned ? '🏅' : '·'} {cert.wpm} WPM + + ))} +
    +
    + ); +} diff --git a/src/components/features/training/TrainingRouter.tsx b/src/components/features/training/TrainingRouter.tsx index a1175e5..bd7fa94 100644 --- a/src/components/features/training/TrainingRouter.tsx +++ b/src/components/features/training/TrainingRouter.tsx @@ -51,6 +51,7 @@ import { EchoDecoderPractice } from './EchoDecoderPractice'; import { EchoSessionResultsView } from './EchoSessionResultsView'; import { EchoTrainingView } from './EchoTrainingView'; import { SessionResultsView } from './SessionResultsView'; +import { TeachingPlanPanel } from './TeachingPlanPanel'; import { TrainingHomeView } from './TrainingHomeView'; interface TrainingRouterProps { @@ -189,6 +190,7 @@ export function TrainingRouter({ stopTrainingIfActive(); setGroupTab('stats'); }} + beforeTipsSlot={} /> ); } diff --git a/src/hooks/useTrainingSession.ts b/src/hooks/useTrainingSession.ts index 806df69..372cc86 100644 --- a/src/hooks/useTrainingSession.ts +++ b/src/hooks/useTrainingSession.ts @@ -7,6 +7,7 @@ import { AUTO_CONFIRM_DELAY_MS, MAX_DIGITS_LEVEL, MAX_KOCH_LEVEL_GUESS, + MIN_DIGITS_LEVEL, } from '@/lib/constants'; import { ensureAppError } from '@/lib/errors'; import { evaluateAutoLevelAdjust } from '@/lib/kochAutoAdjust'; @@ -427,12 +428,12 @@ export function useTrainingSession({ aboveThresholdCount: Math.max(0, currentSettings.autoAdjustAboveThresholdCount ?? 0), belowThresholdCount: Math.max(0, currentSettings.autoAdjustBelowThresholdCount ?? 0), currentLevel: isDigits - ? (currentSettings.digitsLevel ?? 10) + ? (currentSettings.digitsLevel ?? MIN_DIGITS_LEVEL) : currentSettings.kochLevel, maxLevel: isDigits ? MAX_DIGITS_LEVEL : MAX_KOCH_LEVEL_GUESS, ...(isMixed ? { - pairedDigitsLevel: currentSettings.digitsLevel ?? 10, + pairedDigitsLevel: currentSettings.digitsLevel ?? MIN_DIGITS_LEVEL, maxDigitsLevel: MAX_DIGITS_LEVEL, mixedAutoLevelNextAxis: currentSettings.mixedAutoLevelNextAxis ?? 'letters', } diff --git a/src/lib/achievements/evaluateAchievements.ts b/src/lib/achievements/evaluateAchievements.ts index effca51..3fda2b1 100644 --- a/src/lib/achievements/evaluateAchievements.ts +++ b/src/lib/achievements/evaluateAchievements.ts @@ -1,3 +1,4 @@ +import { localDateForTimestamp } from '@/lib/localDate'; import type { SessionResult } from '@/types'; import { ACHIEVEMENT_BADGES } from './badges'; @@ -58,11 +59,16 @@ const countLongestStreak = (dateIndexes: readonly number[]): number => { return longest; }; -const countCurrentStreak = (dateIndexes: readonly number[]): number => { +const countCurrentStreak = (dateIndexes: readonly number[], todayIndex: number | null): number => { const latest = dateIndexes[dateIndexes.length - 1]; if (latest === undefined) { return 0; } + // A streak is only "current" if it reaches today or yesterday (still extendable + // today). A run that ended further in the past is history, not a current streak. + if (todayIndex !== null && latest < todayIndex - 1) { + return 0; + } let streak = 1; for (let index = dateIndexes.length - 2; index >= 0; index -= 1) { @@ -106,12 +112,14 @@ const getMasteredCharacters = ( export const calculateAchievementProgress = ( sessions: readonly SessionResult[], + now = Date.now(), ): AchievementProgress => { const groupSessions = sessions.filter(isGroupSession); const totals = buildCharacterTotals(groupSessions); const masteredLetters = getMasteredCharacters(LETTERS, totals); const masteredDigits = getMasteredCharacters(DIGITS, totals); const dateIndexes = getUniqueDateIndexes(groupSessions); + const todayIndex = toDateIndex(localDateForTimestamp(now)); return { masteredLetters, @@ -123,7 +131,7 @@ export const calculateAchievementProgress = ( bestScore: groupSessions.reduce((best, session) => Math.max(best, session.score), 0), bestAccuracy: groupSessions.reduce((best, session) => Math.max(best, session.accuracy), 0), practiceDays: dateIndexes.length, - currentStreakDays: countCurrentStreak(dateIndexes), + currentStreakDays: countCurrentStreak(dateIndexes, todayIndex), longestStreakDays: countLongestStreak(dateIndexes), }; }; @@ -347,7 +355,7 @@ export const evaluateAchievements = ( existing: readonly UnlockedAchievement[], now = Date.now(), ): AchievementEvaluationResult => { - const progress = calculateAchievementProgress(sessions); + const progress = calculateAchievementProgress(sessions, now); const existingById = new Map( existing.map((achievement) => [achievement.id, achievement] as const), ); diff --git a/src/lib/buildSessionResult.ts b/src/lib/buildSessionResult.ts index 0dc69be..01ad775 100644 --- a/src/lib/buildSessionResult.ts +++ b/src/lib/buildSessionResult.ts @@ -2,6 +2,7 @@ import { calculateGroupLetterAccuracy, calculateOverallCharacterAccuracy, } from '@/lib/groupAlignment'; +import { formatLocalDate } from '@/lib/localDate'; import { calculateAlphabetSize, calculateEffectiveAlphabetSize, @@ -54,7 +55,7 @@ export function buildSessionResult(input: BuildSessionResultInput): SessionResul totalChars, }); - const dateStr = new Date().toISOString().split('T')[0] ?? '1970-01-01'; + const dateStr = formatLocalDate(); return { date: dateStr, @@ -78,6 +79,10 @@ export function buildSessionResult(input: BuildSessionResultInput): SessionResul ...(levelSnapshot.digitsLevel !== undefined ? { digitsLevel: levelSnapshot.digitsLevel } : {}), + ...(levelSnapshot.charWpm !== undefined ? { charWpm: levelSnapshot.charWpm } : {}), + ...(levelSnapshot.effectiveWpm !== undefined + ? { effectiveWpm: levelSnapshot.effectiveWpm } + : {}), } : {}), }; diff --git a/src/lib/curriculum/index.ts b/src/lib/curriculum/index.ts new file mode 100644 index 0000000..6e4a5ab --- /dev/null +++ b/src/lib/curriculum/index.ts @@ -0,0 +1,18 @@ +export { + buildTeachingPlan, + CERTIFICATE_MIN_CHARS, + CERTIFICATE_SPEEDS_WPM, + COPY_TEST_MIN_CHARS, + QUALITY_ACCURACY, + QUALITY_SESSIONS_TARGET, +} from './teachingPlan'; +export type { CurriculumStage } from './teachingPlan'; +export { evaluateTeachingPlan } from './progress'; +export type { + SpeedCertificateProgress, + StageGoalId, + StageGoalProgress, + StageProgress, + StageStatus, + TeachingPlanProgress, +} from './progress'; diff --git a/src/lib/curriculum/progress.ts b/src/lib/curriculum/progress.ts new file mode 100644 index 0000000..ad0607f --- /dev/null +++ b/src/lib/curriculum/progress.ts @@ -0,0 +1,167 @@ +import type { SessionResult } from '@/types'; + +import { + CERTIFICATE_MIN_CHARS, + CERTIFICATE_SPEEDS_WPM, + COPY_TEST_MIN_CHARS, + QUALITY_ACCURACY, + QUALITY_SESSIONS_TARGET, + buildTeachingPlan, + type CurriculumStage, +} from './teachingPlan'; + +export type StageGoalId = 'reach-level' | 'quality-sessions' | 'copy-test'; + +export interface StageGoalProgress { + readonly id: StageGoalId; + readonly label: string; + readonly achieved: boolean; + /** Current progress toward `target` (clamped to target). */ + readonly current: number; + readonly target: number; +} + +export type StageStatus = 'complete' | 'active' | 'upcoming'; + +export interface StageProgress { + readonly stage: CurriculumStage; + readonly status: StageStatus; + readonly goals: readonly StageGoalProgress[]; + /** Best copy-test accuracy achieved at this stage's level (0..1), if attempted. */ + readonly bestCopyTestAccuracy?: number; +} + +export interface SpeedCertificateProgress { + readonly wpm: number; + readonly earned: boolean; + /** Timestamp of the earning session, 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[]; + /** Total stages completed. */ + readonly completedStageCount: number; +} + +const isGroupSession = (session: SessionResult): boolean => + session.mode === undefined || session.mode === 'group'; + +/** Sessions that can prove progress for a stage: group sessions with a recorded level at/above the stage's exit level. */ +const sessionsAtOrAboveLevel = ( + sessions: readonly SessionResult[], + level: number, +): SessionResult[] => + sessions.filter( + (session) => + isGroupSession(session) && + typeof session.kochLevel === 'number' && + session.kochLevel >= level && + (session.charSetMode === undefined || + session.charSetMode === 'koch' || + session.charSetMode === 'mixed'), + ); + +const isCopyTestSession = (session: SessionResult): boolean => + session.totalChars >= COPY_TEST_MIN_CHARS && session.accuracy >= QUALITY_ACCURACY; + +/** + * Evaluate the whole teaching plan against session history. + * + * Sessions recorded at a level at or above a stage's exit level count toward + * that stage, so practicing at level 20 retroactively completes the stages + * below it — the plan recognises demonstrated skill, it never demands + * re-grinding earlier material. + */ +export function evaluateTeachingPlan( + sessions: readonly SessionResult[], + currentKochLevel: number, + stages: readonly CurriculumStage[] = buildTeachingPlan(), +): TeachingPlanProgress { + const stageProgress: StageProgress[] = []; + let activeStageIndex = stages.length; + + stages.forEach((stage) => { + const qualifying = sessionsAtOrAboveLevel(sessions, stage.levelEnd); + const qualitySessions = qualifying.filter((s) => s.accuracy >= QUALITY_ACCURACY); + const copyTests = qualifying.filter(isCopyTestSession); + const copyTestAttempts = qualifying.filter((s) => s.totalChars >= COPY_TEST_MIN_CHARS); + + const reachedLevel = currentKochLevel >= stage.levelEnd || qualifying.length > 0; + const qualityAchieved = qualitySessions.length >= QUALITY_SESSIONS_TARGET; + const copyTestPassed = copyTests.length > 0; + + const goals: StageGoalProgress[] = [ + { + id: 'reach-level', + label: `Reach level ${stage.levelEnd} (${stage.characters.join(' ')})`, + achieved: reachedLevel, + current: reachedLevel ? 1 : 0, + target: 1, + }, + { + id: 'quality-sessions', + label: `${QUALITY_SESSIONS_TARGET} sessions at ≥${Math.round(QUALITY_ACCURACY * 100)}% accuracy`, + achieved: qualityAchieved, + current: Math.min(qualitySessions.length, QUALITY_SESSIONS_TARGET), + target: QUALITY_SESSIONS_TARGET, + }, + { + id: 'copy-test', + label: `Copy test: ${COPY_TEST_MIN_CHARS}+ characters at ≥${Math.round(QUALITY_ACCURACY * 100)}%`, + achieved: copyTestPassed, + current: copyTestPassed ? 1 : 0, + target: 1, + }, + ]; + + const complete = goals.every((goal) => goal.achieved); + const bestCopyTestAccuracy = copyTestAttempts.reduce( + (best, s) => (best === undefined || s.accuracy > best ? s.accuracy : best), + undefined, + ); + + stageProgress.push({ + stage, + status: complete ? 'complete' : 'upcoming', + goals, + ...(bestCopyTestAccuracy !== undefined ? { bestCopyTestAccuracy } : {}), + }); + }); + + // The active stage is the first incomplete one; everything after stays upcoming. + for (let i = 0; i < stageProgress.length; i += 1) { + const progress = stageProgress[i]; + if (progress && progress.status !== 'complete') { + stageProgress[i] = { ...progress, status: 'active' }; + activeStageIndex = i; + break; + } + } + + const certificates: SpeedCertificateProgress[] = CERTIFICATE_SPEEDS_WPM.map((wpm) => { + const earningSession = sessions.find( + (session) => + isGroupSession(session) && + typeof session.charWpm === 'number' && + session.charWpm >= wpm && + session.totalChars >= CERTIFICATE_MIN_CHARS && + session.accuracy >= QUALITY_ACCURACY, + ); + return { + wpm, + earned: earningSession !== undefined, + ...(earningSession !== undefined ? { sessionTimestamp: earningSession.timestamp } : {}), + }; + }); + + return { + stages: stageProgress, + activeStageIndex, + certificates, + completedStageCount: stageProgress.filter((s) => s.status === 'complete').length, + }; +} diff --git a/src/lib/curriculum/teachingPlan.ts b/src/lib/curriculum/teachingPlan.ts new file mode 100644 index 0000000..e27f6b1 --- /dev/null +++ b/src/lib/curriculum/teachingPlan.ts @@ -0,0 +1,78 @@ +import { LCWO_SEQUENCE } from '@/lib/morseConstants'; + +/** + * Structured teaching plan for Morse code. + * + * The plan turns the raw Koch-level ladder into named stages with explicit, + * checkable goals. Each stage covers a slice of the character sequence and is + * completed by demonstrating quality copy — including a "copy test", the + * digital equivalent of the classic solid-copy exam: a sustained run of at + * least {@link COPY_TEST_MIN_CHARS} characters at ≥{@link QUALITY_ACCURACY} + * accuracy with the stage's alphabet. + * + * 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 { + /** Stable id, e.g. `stage-3`. */ + readonly id: string; + /** 0-based position in the plan. */ + readonly index: number; + readonly title: string; + /** First Koch level covered by this stage (inclusive). */ + readonly levelStart: number; + /** Last Koch level covered by this stage (inclusive) — the stage's exit level. */ + readonly levelEnd: number; + /** Characters introduced while progressing through this stage. */ + readonly characters: readonly string[]; +} + +/** Sessions at/above a stage's exit level demonstrating this accuracy count as quality copy. */ +export const QUALITY_ACCURACY = 0.9; +/** Number of quality sessions required to complete a stage. */ +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; +/** Historic FCC code-test speeds (Novice / General / Extra), in WPM. */ +export const CERTIFICATE_SPEEDS_WPM: readonly number[] = [5, 13, 20]; + +const DEFAULT_STAGE_SIZE = 5; + +/** + * Build the staged teaching plan for a character sequence. + * + * Koch level L unlocks the first L+1 characters of the sequence, so a + * sequence of N characters spans levels 1..N-1. Stages chunk that ladder + * into groups of `stageSize` levels. + */ +export function buildTeachingPlan( + sequence: readonly string[] = LCWO_SEQUENCE, + stageSize: number = DEFAULT_STAGE_SIZE, +): CurriculumStage[] { + const maxLevel = Math.max(1, sequence.length - 1); + const size = Math.max(1, Math.floor(stageSize)); + const stages: CurriculumStage[] = []; + + for (let levelStart = 1; levelStart <= maxLevel; levelStart += size) { + const levelEnd = Math.min(levelStart + size - 1, maxLevel); + const index = stages.length; + // Level L introduces sequence[L]; the first stage also owns the two + // baseline characters available at level 1 (sequence[0..1]). + const firstCharIndex = index === 0 ? 0 : levelStart; + const characters = sequence.slice(firstCharIndex, levelEnd + 1); + stages.push({ + id: `stage-${index + 1}`, + index, + title: `Stage ${index + 1}: ${characters.join(' ')}`, + levelStart, + levelEnd, + characters, + }); + } + + return stages; +} diff --git a/src/lib/db/repositories/session.repository.ts b/src/lib/db/repositories/session.repository.ts index 804f62e..56ed8f4 100644 --- a/src/lib/db/repositories/session.repository.ts +++ b/src/lib/db/repositories/session.repository.ts @@ -4,6 +4,7 @@ import { flushPendingOps, loadSessions, saveSessions, + saveSessionsSubset, } from '@/lib/sessionPersistence'; import type { AppUser, SessionResult } from '@/types'; @@ -15,6 +16,12 @@ export interface SessionRepositoryContext { export interface SessionRepository { getAll(context: SessionRepositoryContext): Promise; saveAll(context: SessionRepositoryContext, sessions: readonly SessionResult[]): Promise; + /** Persist only `subset` to the backend while storing the full `allSessions` list locally. */ + saveSubset( + context: SessionRepositoryContext, + subset: readonly SessionResult[], + allSessions: readonly SessionResult[], + ): Promise; deleteByTimestamp( context: SessionRepositoryContext, timestamp: number, @@ -52,6 +59,15 @@ export class FirebaseSessionRepository implements SessionRepository { await saveSessions(context.firebase ?? null, firebaseUser, [...sessions]); } + async saveSubset( + context: SessionRepositoryContext, + subset: readonly SessionResult[], + allSessions: readonly SessionResult[], + ): Promise { + const firebaseUser = toFirebaseUser(context.user); + await saveSessionsSubset(context.firebase ?? null, firebaseUser, [...subset], [...allSessions]); + } + async deleteByTimestamp( context: SessionRepositoryContext, timestamp: number, diff --git a/src/lib/services/session.service.ts b/src/lib/services/session.service.ts index 04e5b3c..babbc71 100644 --- a/src/lib/services/session.service.ts +++ b/src/lib/services/session.service.ts @@ -56,6 +56,8 @@ export class SessionService { kochLevel, digitsLevel, charSetMode, + charWpm, + effectiveWpm, ...rest } = validated; const normalized: SessionResult = { @@ -69,17 +71,23 @@ export class SessionService { ...(kochLevel !== undefined ? { kochLevel } : {}), ...(digitsLevel !== undefined ? { digitsLevel } : {}), ...(charSetMode !== undefined ? { charSetMode } : {}), + ...(charWpm !== undefined ? { charWpm } : {}), + ...(effectiveWpm !== undefined ? { effectiveWpm } : {}), }; - // Try to get existing sessions, but don't fail if Firebase is down + // Merge into the warm cache when available — re-reading the whole session + // collection from Firestore on every save cost O(history) reads. let existing: SessionResult[] = []; - try { - existing = await this.repository.getAll(context); - this.cachedSessions = existing; - } catch (error) { - console.warn('[SessionService] Failed to fetch existing sessions, using cache:', error); - // Use cached sessions if available, otherwise just save the new session - existing = this.cachedSessions ?? []; + if (this.cachedSessions !== null) { + existing = this.cachedSessions; + } else { + try { + existing = await this.repository.getAll(context); + this.cachedSessions = existing; + } catch (error) { + console.warn('[SessionService] Failed to fetch existing sessions, using empty list:', error); + existing = []; + } } const byTimestamp = new Map(existing.map((session) => [session.timestamp, session] as const)); @@ -87,10 +95,11 @@ export class SessionService { const nextSessions = sortSessions(Array.from(byTimestamp.values())); - // Try to save to Firebase, but don't fail if it errors - // The sessionPersistence layer already handles local storage fallback + // Persist only the new/updated session to the backend (the full list is + // still written to local storage). Rewriting the whole history on every + // save cost O(history) Firestore operations per session and hit quota. try { - await this.repository.saveAll(context, nextSessions); + await this.repository.saveSubset(context, [normalized], nextSessions); // Successfully saved to Firebase, remove from retry queue if present dequeueSession(normalized.timestamp); } catch (error) { @@ -128,7 +137,7 @@ export class SessionService { const allSessions = sortSessions(Array.from(byTimestamp.values())); try { - await this.repository.saveAll(context, allSessions); + await this.repository.saveSubset(context, readySessions, allSessions); this.cachedSessions = allSessions; for (const session of readySessions) { recordRetryAttempt(session.timestamp, true); diff --git a/src/lib/sessionLevelSnapshot.ts b/src/lib/sessionLevelSnapshot.ts index c97d0d2..789c817 100644 --- a/src/lib/sessionLevelSnapshot.ts +++ b/src/lib/sessionLevelSnapshot.ts @@ -5,18 +5,33 @@ export type SessionLevelSnapshot = { readonly kochLevel: number; readonly charSetMode: CharacterSetMode; readonly digitsLevel?: number; + /** Character speed (WPM) at session time; lower bound of the configured range. */ + readonly charWpm?: number; + /** Effective (Farnsworth) speed (WPM) at session time; lower bound of the configured range. */ + readonly effectiveWpm?: number; }; export function sessionLevelSnapshotFromSettings( - settings: Pick, + settings: Pick & + Partial>, ): SessionLevelSnapshot { const charSetMode = settings.charSetMode ?? 'koch'; + const charWpm = + typeof settings.charWpmMin === 'number' && settings.charWpmMin >= 1 + ? settings.charWpmMin + : undefined; + const effectiveWpm = + typeof settings.effectiveWpmMin === 'number' && settings.effectiveWpmMin >= 1 + ? Math.min(settings.effectiveWpmMin, charWpm ?? settings.effectiveWpmMin) + : undefined; return { kochLevel: settings.kochLevel, charSetMode, ...(charSetMode === 'digits' || charSetMode === 'mixed' ? { digitsLevel: settings.digitsLevel ?? 10 } : {}), + ...(charWpm !== undefined ? { charWpm } : {}), + ...(effectiveWpm !== undefined ? { effectiveWpm } : {}), }; } diff --git a/src/lib/utils/icrSessionFormatter.ts b/src/lib/utils/icrSessionFormatter.ts index 5856cc1..c681e62 100644 --- a/src/lib/utils/icrSessionFormatter.ts +++ b/src/lib/utils/icrSessionFormatter.ts @@ -1,3 +1,4 @@ +import { localDateForTimestamp } from '@/lib/localDate'; import type { IcrSettings, IcrSessionResult, IcrTrialResult, IcrAudioSnapshot, IcrLetterStats } from '@/types'; export type RawIcrTrial = { @@ -157,9 +158,7 @@ export const formatSession = ({ const timestampValue = typeof timestamp === 'number' && Number.isFinite(timestamp) ? Math.round(timestamp) : Date.now(); - const dateStr = new Date(timestampValue).toISOString().split('T')[0]; - const date = dateStr || new Date().toISOString().split('T')[0] || ''; - if (!date) throw new Error('Failed to generate date string'); + const date = localDateForTimestamp(timestampValue); const reactionSamples = answeredTrials .map((trial) => trial.reactionMs) diff --git a/src/lib/validators/session-result.schema.ts b/src/lib/validators/session-result.schema.ts index 2415e14..83b2844 100644 --- a/src/lib/validators/session-result.schema.ts +++ b/src/lib/validators/session-result.schema.ts @@ -37,9 +37,13 @@ export const sessionResultSchema = z score: z.number().min(0), mode: z.enum(['group', 'echo', 'chase']).optional(), firestoreId: z.string().min(1).optional(), - kochLevel: z.number().int().min(1).max(40).optional(), + // Max matches MAX_KOCH_LEVEL_GUESS (custom sequences can exceed the 40-level + // LCWO sequence; a stricter cap made auto-levelled sessions fail validation). + kochLevel: z.number().int().min(1).max(60).optional(), digitsLevel: z.number().int().min(1).max(10).optional(), charSetMode: sessionCharSetModeSchema.optional(), + charWpm: z.number().min(1).max(100).optional(), + effectiveWpm: z.number().min(1).max(100).optional(), }) .superRefine((value, ctx) => { if (value.groupTimings.length !== value.groups.length) { diff --git a/src/store/providers/app-store-provider.tsx b/src/store/providers/app-store-provider.tsx index f1a4df9..198a5c5 100644 --- a/src/store/providers/app-store-provider.tsx +++ b/src/store/providers/app-store-provider.tsx @@ -27,7 +27,7 @@ import type { IcrSessionService as IcrSessionServiceType } from '@/lib/services/ import type { SessionService as SessionServiceType } from '@/lib/services/session.service'; import type { TrainingSettingsService as TrainingSettingsServiceType } from '@/lib/services/training-settings.service'; import type { FirebaseServicesLite } from '@/lib/sessionPersistence'; -import { getQueuedSessionCount } from '@/lib/sessionQueue'; +import { getQueuedSessionCount, resetFailedSessions } from '@/lib/sessionQueue'; import type { AppUser } from '@/types'; import { contextEquals } from '../context-utils'; @@ -389,6 +389,10 @@ export function AppStoreProvider({ useEffect(() => { const RETRY_INTERVAL_MS = 30000; // Check every 30 seconds + // Sessions that exhausted their retry budget stay parked until attempts are + // reset; a fresh app load is the promised retry point, so reset them here. + resetFailedSessions(); + // Initial retry attempt after a short delay const initialTimeout = setTimeout(() => { void tryRetryQueuedSessionsRef.current?.(); diff --git a/src/types/domain.ts b/src/types/domain.ts index 5643369..cc3341a 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -262,6 +262,10 @@ export interface SessionResult { readonly digitsLevel?: number; /** Character-set mode at session time. */ readonly charSetMode?: CharacterSetMode; + /** Character (element) speed in WPM at session time — lower bound of the configured range. */ + readonly charWpm?: number; + /** Effective (Farnsworth) speed in WPM at session time — lower bound of the configured range. */ + readonly effectiveWpm?: number; } /** Aggregate statistics for calendar heatmap visualisations. */ diff --git a/types/firebase.d.ts b/types/firebase.d.ts index 221f534..cd18d3c 100644 --- a/types/firebase.d.ts +++ b/types/firebase.d.ts @@ -34,6 +34,7 @@ declare module 'firebase/firestore' { export const setDoc: any; export const deleteDoc: any; export const where: any; + export const arrayUnion: any; }