diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..7233a6a --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,30 @@ +# Roadmap: items that need a backend + +Two hardening/engagement items cannot be completed client-side and are +deliberately deferred rather than half-done: + +## Server-side score authority + +Leaderboard scores are computed in the browser. Firestore rules now validate +shape and bounds (`firestore.rules`) and the volume term is capped +(`MAX_SCORED_CHARS` in `lib/score.ts`), which bounds — but cannot eliminate — +forged scores. The complete fix is a Cloud Function that recomputes the score +from the submitted groups/timings on write and rejects mismatches. The session +payload already contains everything needed; no client changes are required +beyond deleting the client-side leaderboard write. + +## Push practice reminders + +The streak card covers in-app urgency, but true "your streak ends in 3 hours" +notifications require Web Push (a push service + subscription storage), which +needs server infrastructure. The service worker is already in place +(`public/sw.js`); adding `pushManager` subscription handling and an FCM topic +per user is the missing half. + +## Firestore configuration notes + +- Deploy rules after every change: `npm run deploy:firestore`. +- The weekly leaderboard tab queries `collectionGroup('leaderboard')` filtered + by `date`; if the console reports a missing index, enable collection-group + scope for the `date` field (single-field index settings) or create the + suggested index from the error link. The UI degrades gracefully until then. diff --git a/firestore.rules b/firestore.rules index f718184..58288a9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,6 +2,16 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { + function isOwner(userId) { + return request.auth != null && request.auth.uid == userId; + } + + // Amateur-callsign-shaped identifier: uppercase alphanumerics with optional + // portable suffix (e.g. SP9XYZ, K1ABC/P). Blocks HTML, spaces, and novels. + function validCallSign(cs) { + return cs is string && cs.size() >= 3 && cs.size() <= 12 && cs.matches('^[A-Z0-9/]+$'); + } + // 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. @@ -14,40 +24,62 @@ service cloud.firestore { && 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)); + && (!('callSign' in d) || d.callSign == null || validCallSign(d.callSign)); } + // NOTE: user data is matched per-subcollection on purpose. Firestore grants + // access when ANY match allows it, so a users/{userId}/{document=**} + // catch-all would silently bypass every validated rule below. + // 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 create entries, and only well-formed ones - allow create: if request.auth != null && request.auth.uid == userId - && isValidLeaderboardEntry(); + allow create: if isOwner(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 update: if isOwner(userId) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['callSign']) + && (!('callSign' in request.resource.data) + || request.resource.data.callSign == null + || validCallSign(request.resource.data.callSign)); + allow delete: if isOwner(userId); } - // Allow reading call-sign from any user's profile for leaderboard display - match /users/{userId}/meta/profile { - allow read: if request.auth != null; - // Only the owner can write their own profile - allow write: if request.auth != null && request.auth.uid == userId; + // meta/profile is readable by any signed-in user (leaderboard call-sign + // display); other meta docs (deletedSessions, autoAdjust, achievements) + // are private. A single match with conditions avoids overlapping rules. + match /users/{userId}/meta/{metaDoc} { + allow read: if isOwner(userId) || (metaDoc == 'profile' && request.auth != null); + allow write: if isOwner(userId) + && (metaDoc != 'profile' + || !('callSign' in request.resource.data) + || request.resource.data.callSign == null + || validCallSign(request.resource.data.callSign)); } // Public trophy/profile snapshots (doc id = Firebase uid; publicId is a field for URLs). match /publicProfiles/{profileUid} { allow read: if true; - allow create, update: if request.auth != null && request.auth.uid == profileUid; + allow create, update: if request.auth != null && request.auth.uid == profileUid + && (!('callSign' in request.resource.data) + || request.resource.data.callSign == null + || validCallSign(request.resource.data.callSign)); allow delete: if request.auth != null && request.auth.uid == profileUid; } - // Protect all other user data - users can only access their own data - match /users/{userId}/{document=**} { - allow read, write: if request.auth != null && request.auth.uid == userId; + // Private user data — owner only. + match /users/{userId}/sessions/{document=**} { + allow read, write: if isOwner(userId); + } + match /users/{userId}/stats/{document=**} { + allow read, write: if isOwner(userId); + } + match /users/{userId}/settings/{document=**} { + allow read, write: if isOwner(userId); + } + match /users/{userId}/clientErrors/{document=**} { + allow read, write: if isOwner(userId); } } } - diff --git a/lib/kochAutoAdjust.ts b/lib/kochAutoAdjust.ts index d7f8ee5..de1875c 100644 --- a/lib/kochAutoAdjust.ts +++ b/lib/kochAutoAdjust.ts @@ -93,6 +93,78 @@ export interface AutoLevelAdjustProgressView { // ── Local-storage helpers ────────────────────────────────────────────── +const AUTO_ADJUST_KEY_PREFIX = 'cw_auto_adjust_'; + +/** + * Cross-device sync hook. Counters live in localStorage for synchronous access + * during result processing, but localStorage is per-browser — without a mirror, + * progress toward the next level silently resets on every new device while + * sessions themselves sync. The app store registers a listener that persists + * the exported snapshot to the user's Firestore meta document. + */ +type AutoAdjustSyncListener = (counters: Record) => void; +let autoAdjustSyncListener: AutoAdjustSyncListener | null = null; + +export function registerAutoAdjustSyncListener(listener: AutoAdjustSyncListener | null): void { + autoAdjustSyncListener = listener; +} + +function notifyAutoAdjustSync(): void { + if (!autoAdjustSyncListener) return; + try { + autoAdjustSyncListener(exportAutoAdjustCounters()); + } catch { + /* sync is best-effort */ + } +} + +/** Snapshot every persisted counter, keyed by storage key suffix (mode_level[_digits]). */ +export function exportAutoAdjustCounters(): Record { + const out: Record = {}; + try { + if (typeof window === 'undefined') return out; + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (!key || !key.startsWith(AUTO_ADJUST_KEY_PREFIX)) continue; + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(key) ?? ''); + if (typeof parsed === 'object' && parsed !== null) { + const obj = parsed as Record; + out[key.slice(AUTO_ADJUST_KEY_PREFIX.length)] = { + above: typeof obj['above'] === 'number' ? obj['above'] : 0, + below: typeof obj['below'] === 'number' ? obj['below'] : 0, + }; + } + } catch { + /* skip corrupted entry */ + } + } + } catch { + /* localStorage unavailable */ + } + return out; +} + +/** Hydrate counters from a cloud snapshot. Local values win — cloud only fills gaps. */ +export function importAutoAdjustCounters( + counters: Readonly>, +): void { + try { + if (typeof window === 'undefined') return; + Object.entries(counters).forEach(([suffix, value]) => { + if (!suffix || typeof value !== 'object' || value === null) return; + const key = `${AUTO_ADJUST_KEY_PREFIX}${suffix}`; + if (window.localStorage.getItem(key) !== null) return; // local wins + const above = typeof value.above === 'number' && value.above >= 0 ? value.above : 0; + const below = typeof value.below === 'number' && value.below >= 0 ? value.below : 0; + if (above === 0 && below === 0) return; + window.localStorage.setItem(key, JSON.stringify({ above, below })); + }); + } catch { + /* localStorage unavailable */ + } +} + function isMixedAdjustMode(mode: AutoAdjustMode): boolean { return mode === 'mixed' || mode === 'echo-mixed'; } @@ -147,6 +219,7 @@ function saveCounts( } catch { /* localStorage quota or privacy error */ } + notifyAutoAdjustSync(); } function removeLevelCounts( @@ -161,6 +234,7 @@ function removeLevelCounts( } catch { /* no-op */ } + notifyAutoAdjustSync(); } // ── Mode labels ───────────────────────────────────────────────────────── diff --git a/lib/score.ts b/lib/score.ts index 35907de..d3111f8 100644 --- a/lib/score.ts +++ b/lib/score.ts @@ -14,6 +14,14 @@ export const DEFAULT_SCORE_CONSTANTS: ScoreConstants = { delta: 0.5, }; +/** + * Cap on the character-volume term. Without it the C^delta term is unbounded, + * so on a best-session leaderboard the longest good session always wins — + * rewarding stamina over skill. 200 characters (~2 sustained copy tests) is + * where extra volume stops proving anything new. + */ +export const MAX_SCORED_CHARS = 200; + function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(max, Math.max(min, value)); @@ -112,7 +120,7 @@ export function computeSessionScore(params: { const N = Math.max(1, Number((params.effectiveAlphabetSize ?? params.alphabetSize) || 0)); const A = clampNumber(params.accuracy || 0, 0, 1); const tAvg = Math.max(1, Math.round(params.avgResponseMs || 0)); - const C = Math.max(1, Math.floor(params.totalChars || 0)); + const C = Math.min(MAX_SCORED_CHARS, Math.max(1, Math.floor(params.totalChars || 0))); const termAlphabet = Math.pow(N, c.alpha); const termAccuracy = Math.pow(A, c.beta); @@ -124,16 +132,33 @@ export function computeSessionScore(params: { return Math.round(score * 100) / 100; } -// Deterministic public numeric ID from Firebase UID (djb2 variant), 6 digits minimum +// Deterministic public numeric ID from a Firebase UID. +// +// New users get a 9-digit ID (FNV-1a over the UID): a 900M space keeps +// birthday-collision odds negligible until ~35k users, versus ~1.1k users for +// the legacy 6-digit space. Existing users keep their stored 6-digit IDs — +// ensurePublicId never rewrites an ID that already exists, so share URLs +// stay stable and the two ranges cannot collide with each other. export function derivePublicIdFromUid(uid: string): number { + let hash = 0x811c9dc5; // FNV-1a offset basis + for (let i = 0; i < uid.length; i++) { + hash ^= uid.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); // FNV prime + } + const positive = hash >>> 0; + const id = (positive % 900000000) + 100000000; // 100000000..999999999 + return id; +} + +/** @deprecated Legacy 6-digit derivation — kept only to document the historical range. */ +export function deriveLegacyPublicIdFromUid(uid: string): number { let hash = 5381; for (let i = 0; i < uid.length; i++) { hash = ((hash << 5) + hash) + uid.charCodeAt(i); // hash * 33 + char hash |= 0; // 32-bit } const positive = Math.abs(hash); - const id = (positive % 900000) + 100000; // 100000..999999 - return id; + return (positive % 900000) + 100000; // 100000..999999 } diff --git a/lib/sessionPersistence.ts b/lib/sessionPersistence.ts index ad760f6..c89aebd 100644 --- a/lib/sessionPersistence.ts +++ b/lib/sessionPersistence.ts @@ -134,6 +134,8 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi // Use group alignment for accurate letter-level accuracy calculation return calculateGroupLetterAccuracy(groups); })(); + const hasRealTimestamp = + typeof rawObj['timestamp'] === 'number' || (opts?.docId !== undefined && /^\d+$/.test(opts.docId)); const ts = typeof rawObj['timestamp'] === 'number' ? (rawObj['timestamp'] as number) @@ -141,7 +143,15 @@ 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 : localDateForTimestamp(ts); + // Derive the calendar day from the timestamp in the viewer's timezone. This is + // the zero-write migration for legacy sessions whose stored date was the UTC + // day: streaks and the heatmap read consistent local days without rewriting + // any documents. The stored date only survives when there is no timestamp. + const date: string = hasRealTimestamp + ? localDateForTimestamp(ts) + : 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; @@ -261,6 +271,9 @@ async function recordDeletionTombstone( } } +/** Tombstones beyond this count are compacted away (oldest sessions first). */ +const MAX_DELETION_TOMBSTONES = 500; + async function readDeletionTombstones( services: FirebaseServicesLite, user: { uid?: string } | null, @@ -276,6 +289,16 @@ async function readDeletionTombstones( if (typeof t === 'number' && Number.isFinite(t)) tombstones.add(t); }); } + // Compact unbounded growth: old tombstones only matter while some device + // still holds the deleted session locally, and devices holding sessions + // that old have long since synced. Keep the newest N (fire-and-forget). + if (tombstones.size > MAX_DELETION_TOMBSTONES) { + const kept = [...tombstones].sort((a, b) => b - a).slice(0, MAX_DELETION_TOMBSTONES); + void setDoc(doc(services.db, 'users', user.uid, 'meta', 'deletedSessions'), { + timestamps: kept, + updatedAt: Date.now(), + }).catch(() => {}); + } } } catch { // Unavailable tombstones only mean a potential re-upload, never data loss. @@ -323,15 +346,28 @@ export async function getUserCallSign( } } +/** Callsign-shaped: uppercase alphanumerics with optional portable suffix (matches firestore.rules). */ +export const CALL_SIGN_PATTERN = /^[A-Z0-9/]{3,12}$/; + +export function isValidCallSign(callSign: string): boolean { + return CALL_SIGN_PATTERN.test(callSign.trim().toUpperCase()); +} + export async function setUserCallSign( services: FirebaseServicesLite, user: { uid: string } | null, callSign: string | null, ): Promise { if (!(services && user && user.uid)) return; + const trimmed = callSign ? callSign.trim().toUpperCase() : null; + if (trimmed && trimmed.length > 0 && !CALL_SIGN_PATTERN.test(trimmed)) { + throw new Error( + 'Call-sign must be 3-12 characters: letters, digits, and "/" only (e.g. SP9XYZ or K1ABC/P).', + ); + } try { const profileDocRef = doc(services.db, 'users', user.uid, 'meta', 'profile'); - const cleaned = callSign ? callSign.trim().toUpperCase() : null; + const cleaned = trimmed; const update: any = { updatedAt: Date.now() }; if (cleaned && cleaned.length > 0) { update.callSign = cleaned; diff --git a/src/__tests__/lib/score.test.ts b/src/__tests__/lib/score.test.ts new file mode 100644 index 0000000..c84b059 --- /dev/null +++ b/src/__tests__/lib/score.test.ts @@ -0,0 +1,27 @@ +import { MAX_SCORED_CHARS, computeSessionScore } from '@/lib/score'; + +describe('computeSessionScore', () => { + const base = { + effectiveAlphabetSize: 10, + accuracy: 0.95, + avgResponseMs: 1000, + }; + + it('rewards more characters up to the volume cap', () => { + const short = computeSessionScore({ ...base, totalChars: 50 }); + const longer = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS }); + expect(longer).toBeGreaterThan(short); + }); + + it('stops rewarding volume beyond the cap so grinding cannot outscore skill', () => { + const atCap = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS }); + const wayPastCap = computeSessionScore({ ...base, totalChars: MAX_SCORED_CHARS * 50 }); + expect(wayPastCap).toBe(atCap); + }); + + it('still ranks accuracy and speed above capped volume', () => { + const grinder = computeSessionScore({ ...base, accuracy: 0.8, totalChars: 100000 }); + const sharpshooter = computeSessionScore({ ...base, accuracy: 1, totalChars: MAX_SCORED_CHARS }); + expect(sharpshooter).toBeGreaterThan(grinder); + }); +}); diff --git a/src/__tests__/lib/streak.test.ts b/src/__tests__/lib/streak.test.ts new file mode 100644 index 0000000..61016a6 --- /dev/null +++ b/src/__tests__/lib/streak.test.ts @@ -0,0 +1,83 @@ +import { computeStreakStatus } from '@/lib/streak'; + +const dayMs = 24 * 60 * 60 * 1000; +const dateAt = (base: number, offsetDays: number): string => + new Date(base + offsetDays * dayMs).toISOString().slice(0, 10); + +// Noon UTC keeps the local calendar date stable for test runners within ±11h of UTC. +const NOW = Date.parse('2026-07-17T12:00:00.000Z'); +const TODAY = Date.parse('2026-07-17T00:00:00.000Z'); + +describe('computeStreakStatus', () => { + it('returns none with no history', () => { + expect(computeStreakStatus([], NOW).state).toBe('none'); + }); + + it('is safe when practiced today', () => { + const dates = [dateAt(TODAY, -2), dateAt(TODAY, -1), dateAt(TODAY, 0)]; + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('safe'); + expect(status.days).toBe(3); + }); + + it('is at risk when the streak reaches yesterday but not today', () => { + const dates = [dateAt(TODAY, -3), dateAt(TODAY, -2), dateAt(TODAY, -1)]; + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('at-risk'); + expect(status.days).toBe(3); + }); + + it('earns a freeze after 7 practiced days and spends it on a missed day', () => { + // 7 consecutive days earn one freeze, then one missed day, then practice resumes. + const dates = [ + ...Array.from({ length: 7 }, (_, i) => dateAt(TODAY, -10 + i)), // days -10..-4 + // day -3 missed (freeze consumed) + dateAt(TODAY, -2), + dateAt(TODAY, -1), + dateAt(TODAY, 0), + ]; + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('safe'); + expect(status.days).toBe(10); // 7 + frozen continuation + 3 more practiced days + expect(status.freezesUsed).toBe(1); + }); + + it('breaks the streak when the gap exceeds available freezes', () => { + const dates = [ + dateAt(TODAY, -8), + dateAt(TODAY, -7), + dateAt(TODAY, -6), + // -5..-3 missed: 3-day gap, no freezes earned yet + dateAt(TODAY, -2), + dateAt(TODAY, -1), + dateAt(TODAY, 0), + ]; + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('safe'); + expect(status.days).toBe(3); + }); + + it('reports a recently lost streak', () => { + const dates = [dateAt(TODAY, -6), dateAt(TODAY, -5), dateAt(TODAY, -4)]; + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('lost'); + expect(status.days).toBe(0); + expect(status.lostStreakDays).toBe(3); + }); + + it('shows nothing once a lapse is old news', () => { + const dates = [dateAt(TODAY, -40), dateAt(TODAY, -39)]; + expect(computeStreakStatus(dates, NOW).state).toBe('none'); + }); + + it('keeps an at-risk streak alive across missed days covered by banked freezes', () => { + // 14 practiced days bank two freezes; yesterday missed, today not yet practiced. + const dates = Array.from({ length: 14 }, (_, i) => dateAt(TODAY, -16 + i)); // -16..-3 + // day -2 missed, day -1 missed → both covered by the two banked freezes + const status = computeStreakStatus(dates, NOW); + expect(status.state).toBe('at-risk'); + expect(status.days).toBe(14); + expect(status.freezesAvailable).toBe(0); + expect(status.freezesUsed).toBe(2); + }); +}); diff --git a/src/components/features/stats/Leaderboard.tsx b/src/components/features/stats/Leaderboard.tsx index f58dbdd..1e508a7 100644 --- a/src/components/features/stats/Leaderboard.tsx +++ b/src/components/features/stats/Leaderboard.tsx @@ -9,6 +9,7 @@ import { limit, orderBy, query, + where, } from 'firebase/firestore'; import katex from 'katex'; import 'katex/dist/katex.min.css'; @@ -47,6 +48,19 @@ type LeaderboardDoc = { const formatPublicId = (n: number): string => String(n).padStart(6, '0'); +export type LeaderboardPeriod = 'all' | 'week'; + +/** Monday of the current week (local time) as YYYY-MM-DD, matching session date format. */ +function currentWeekStartDate(now = new Date()): string { + const day = now.getDay(); // 0 = Sunday + const daysSinceMonday = (day + 6) % 7; + const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceMonday); + const y = monday.getFullYear(); + const m = String(monday.getMonth() + 1).padStart(2, '0'); + const d = String(monday.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.Element { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -54,6 +68,7 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E const [hint, setHint] = useState(null); const [showHelp, setShowHelp] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [period, setPeriod] = useState('all'); const hasMounted = useHasMounted(); useEffect(() => { @@ -75,7 +90,8 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E // 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); + // 3x balances board fill against per-view read cost. + const fetchLimit = Math.min(200, limitN * 3); let lastErrorCode: string | null = null; const attempt = async (makeQuery: () => ReturnType): Promise => { @@ -199,6 +215,46 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E }; let ok = false; + if (period === 'week') { + // Weekly board: filter by date, sort client-side (a range filter and a + // different orderBy field can't be combined without a composite + // collection-group index). Entries are pre-sorted and deduped in + // attempt(), so fetching by date is enough. + const weekStart = currentWeekStartDate(); + try { + ok = await attempt(() => + query( + collectionGroup(services.db, 'leaderboard'), + where('date', '>=', weekStart), + limit(Math.max(fetchLimit, 200)), + ), + ); + } catch (_error) { + ok = false; + } + if (!ok && services.auth?.currentUser?.uid) { + const uid = services.auth.currentUser.uid; + try { + ok = await attempt(() => + query( + collection(services.db, 'users', uid, 'leaderboard'), + where('date', '>=', weekStart), + limit(Math.max(fetchLimit, 200)), + ), + ); + if (ok) setHint('Weekly board is limited to your own sessions right now.'); + } catch (_error) { + ok = false; + } + } + if (!ok) { + setHint('Weekly leaderboard unavailable (may need a Firestore index).'); + setItems([]); + return; + } + setItems(rows.sort((a, b) => b.score - a.score)); + return; + } // Try global leaderboard first (collectionGroup) try { ok = await attempt(() => @@ -278,7 +334,7 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E return (): void => { cancelled = true; }; - }, [limitCount, refreshKey]); + }, [limitCount, refreshKey, period]); // Auto-refresh every 30 seconds to pick up call-sign updates useEffect(() => { @@ -345,6 +401,31 @@ export function Leaderboard({ limitCount = 20 }: { limitCount?: number }): JSX.E > ↻ +
+ + +
Top {limitCount}
diff --git a/src/components/features/training/SessionResultsView.tsx b/src/components/features/training/SessionResultsView.tsx index 3e51a62..8665e49 100644 --- a/src/components/features/training/SessionResultsView.tsx +++ b/src/components/features/training/SessionResultsView.tsx @@ -21,6 +21,8 @@ export interface SessionResultsViewProps { readonly onViewStats: () => void; readonly onBack: () => void; readonly unlockedAchievements?: readonly UnlockedAchievement[]; + /** Optional "what's next" region (personal best delta, nearest plan goal). */ + readonly nextUpSlot?: React.ReactNode; } export function SessionResultsView({ @@ -29,6 +31,7 @@ export function SessionResultsView({ onViewStats, onBack, unlockedAchievements = [], + nextUpSlot, }: SessionResultsViewProps): JSX.Element { return (
@@ -98,6 +101,8 @@ export function SessionResultsView({
+ {nextUpSlot != null ? nextUpSlot : null} + {/* Group Results */}

diff --git a/src/components/features/training/TeachingPlanPanel.tsx b/src/components/features/training/TeachingPlanPanel.tsx index 045223a..1ff0dea 100644 --- a/src/components/features/training/TeachingPlanPanel.tsx +++ b/src/components/features/training/TeachingPlanPanel.tsx @@ -153,28 +153,63 @@ export function TeachingPlanPanel({ sessions, settings }: TeachingPlanPanelProps ) : null}

-
-

- Copy certificates +

+
+

+ Copy certificates +

+ {progress.certificates.map((cert) => ( + + {cert.earned ? '🏅' : '·'} {cert.wpm} WPM + + ))} + {progress.certificates.some((cert) => cert.earned) ? ( + + ) : null} +
+

+ Certificates count sessions recorded after speed tracking was added — history without a + recorded speed can't be verified.

- {progress.certificates.map((cert) => ( - - {cert.earned ? '🏅' : '·'} {cert.wpm} WPM - - ))}
); } + +async function shareCertificates( + certificates: readonly { readonly wpm: number; readonly earned: boolean }[], +): Promise { + const earned = certificates.filter((cert) => cert.earned).map((cert) => `${cert.wpm} WPM`); + if (earned.length === 0) return; + const text = `Solid-copy Morse certificate${earned.length > 1 ? 's' : ''} earned: ${earned.join(', ')} — trained with CW Trainer. 🎧🔑`; + try { + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + await navigator.share({ text }); + return; + } + if (typeof navigator !== 'undefined' && navigator.clipboard) { + await navigator.clipboard.writeText(text); + } + } catch { + /* user cancelled or share unavailable */ + } +} diff --git a/src/components/features/training/TrainingHomeView.tsx b/src/components/features/training/TrainingHomeView.tsx index cdaa92b..98bc5aa 100644 --- a/src/components/features/training/TrainingHomeView.tsx +++ b/src/components/features/training/TrainingHomeView.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react'; import { ActivityHeatmap } from '@/components/ui/charts/ActivityHeatmap'; import { AutoLevelAdjustProgressCard } from '@/components/ui/training/AutoLevelAdjustProgressCard'; import { NewLetterPlayer } from '@/components/ui/training/NewLetterPlayer'; +import { StreakCard } from '@/components/ui/training/StreakCard'; import { useAutoLevelAdjustProgress } from '@/hooks/useAutoLevelAdjustProgress'; import type { AutoAdjustProfileVariant } from '@/lib/kochAutoAdjust'; import { settingsToSharedAudioProps } from '@/lib/settingsToSharedAudioProps'; @@ -128,6 +129,8 @@ export function TrainingHomeView({

{description}

+ s.date)} /> +

+ } onTrainAgain={() => { clearLatestUnlockedAchievements(); training.dismissResults(); @@ -190,7 +200,12 @@ export function TrainingRouter({ stopTrainingIfActive(); setGroupTab('stats'); }} - beforeTipsSlot={} + beforeTipsSlot={ +
+ + +
+ } /> ); } diff --git a/src/components/ui/training/NextGoalCard.tsx b/src/components/ui/training/NextGoalCard.tsx new file mode 100644 index 0000000..0b157b1 --- /dev/null +++ b/src/components/ui/training/NextGoalCard.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React, { useMemo } from 'react'; + +import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum'; +import type { SessionResult, TrainingSettings } from '@/types'; + +export interface NextGoalCardProps { + readonly sessions: readonly SessionResult[]; + readonly settings: Pick; + readonly lastScore: number; +} + +const isGroupSession = (session: SessionResult): boolean => + session.mode === undefined || session.mode === 'group'; + +/** + * Results-screen cliffhanger: how this session compares to the personal best, + * and the nearest unfinished teaching-plan goal — a concrete reason to press + * "Train Again" instead of closing the tab. + */ +export function NextGoalCard({ sessions, settings, lastScore }: NextGoalCardProps): JSX.Element | null { + const bestLine = useMemo(() => { + const otherScores = sessions + .filter(isGroupSession) + .map((s) => s.score) + .filter((score) => Number.isFinite(score)); + const best = otherScores.length > 0 ? Math.max(...otherScores) : 0; + if (best <= 0) return null; + if (lastScore >= best) { + return { emoji: '🏆', text: 'New personal best score!' }; + } + const gap = Math.max(1, Math.round(best - lastScore)); + return { emoji: '📈', text: `${gap} points off your personal best (${Math.round(best)}).` }; + }, [sessions, lastScore]); + + const goalLine = useMemo(() => { + const sequence = + Array.isArray(settings.customSequence) && settings.customSequence.length > 0 + ? settings.customSequence + : undefined; + const plan = sequence ? buildTeachingPlan(sequence) : buildTeachingPlan(); + const progress = evaluateTeachingPlan(sessions, settings.kochLevel, plan); + const active = progress.stages[progress.activeStageIndex]; + if (!active) return null; + const nextGoal = active.goals.find((goal) => !goal.achieved); + if (!nextGoal) return null; + const stageNumber = active.stage.index + 1; + if (nextGoal.id === 'quality-sessions') { + const remaining = Math.max(1, nextGoal.target - nextGoal.current); + return `${remaining} more session${remaining > 1 ? 's' : ''} at ≥90% completes a Stage ${stageNumber} goal.`; + } + if (nextGoal.id === 'copy-test') { + return `Pass the Stage ${stageNumber} copy test: 100+ characters at ≥90%.`; + } + return `Next up: reach level ${active.stage.levelEnd} to work on Stage ${stageNumber}.`; + }, [sessions, settings.kochLevel, settings.customSequence]); + + if (!bestLine && !goalLine) return null; + + return ( +
+ {bestLine ? ( +

+ {bestLine.emoji} + {bestLine.text} +

+ ) : null} + {goalLine ? ( +

+ 🎯 + {goalLine} +

+ ) : null} +
+ ); +} diff --git a/src/components/ui/training/StreakCard.tsx b/src/components/ui/training/StreakCard.tsx new file mode 100644 index 0000000..6040ea5 --- /dev/null +++ b/src/components/ui/training/StreakCard.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React, { useMemo } from 'react'; + +import { computeStreakStatus } from '@/lib/streak'; + +export interface StreakCardProps { + readonly practiceDates: readonly string[]; +} + +/** + * Home-screen streak banner: the daily reason to press Start. + * safe → celebrate; at-risk → urgency; lost → soft restart invitation. + */ +export function StreakCard({ practiceDates }: StreakCardProps): JSX.Element | null { + const status = useMemo(() => computeStreakStatus(practiceDates), [practiceDates]); + + if (status.state === 'none') { + return null; + } + + const freezeChip = + status.freezesAvailable > 0 ? ( + + 🧊 ×{status.freezesAvailable} + + ) : null; + + if (status.state === 'safe') { + return ( +
+ 🔥 +

+ {status.days}-day streak — today is in the bag. + {status.freezesUsed > 0 ? ` (${status.freezesUsed} freeze${status.freezesUsed > 1 ? 's' : ''} used)` : ''} +

+ {freezeChip} +
+ ); + } + + if (status.state === 'at-risk') { + return ( +
+ ⚠️ +

+ {status.days}-day streak at risk — practice today to + keep it alive. +

+ {freezeChip} +
+ ); + } + + return ( +
+ 💔 +

+ Your {status.lostStreakDays}-day streak ended — one + session today starts the next one. +

+
+ ); +} diff --git a/src/components/ui/training/TodaysFocusCard.tsx b/src/components/ui/training/TodaysFocusCard.tsx new file mode 100644 index 0000000..d3daf70 --- /dev/null +++ b/src/components/ui/training/TodaysFocusCard.tsx @@ -0,0 +1,82 @@ +'use client'; + +import React, { useMemo } from 'react'; + +import { aggregateHistoricalBeliefs, betaPosteriorMeanError } from '@/lib/training/charSampling'; +import { computeCharPool } from '@/lib/trainingUtils'; +import type { SessionResult, TrainingSettings } from '@/types'; + +export interface TodaysFocusCardProps { + readonly sessions: readonly SessionResult[]; + readonly settings: TrainingSettings; +} + +const MIN_ATTEMPTS = 5; +const MIN_ERROR_TO_SHOW = 0.12; +const MAX_FOCUS_CHARS = 3; + +/** + * Surfaces the adaptive sampler's judgement on the home screen. The Bayesian + * weak-character targeting is the trainer's most distinctive feature but was + * invisible — naming today's weak spots turns it into a daily reason to start. + */ +export function TodaysFocusCard({ sessions, settings }: TodaysFocusCardProps): JSX.Element | null { + const focus = useMemo(() => { + // Settings loaded from storage can predate newer fields — treat every + // optional-shaped field defensively even where the type claims otherwise. + const customSet = Array.isArray(settings.customSet) ? settings.customSet : []; + const customSequence = Array.isArray(settings.customSequence) ? settings.customSequence : []; + const pool = computeCharPool({ + kochLevel: settings.kochLevel, + charSetMode: settings.charSetMode, + digitsLevel: settings.digitsLevel, + ...(customSet.length > 0 ? { customSet: [...customSet] } : {}), + ...(customSequence.length > 0 ? { customSequence: [...customSequence] } : {}), + ...(settings.slidingWindowStart !== undefined + ? { slidingWindowStart: settings.slidingWindowStart } + : {}), + ...(settings.slidingWindowEnd !== undefined + ? { slidingWindowEnd: settings.slidingWindowEnd } + : {}), + }); + const beliefs = aggregateHistoricalBeliefs( + sessions.filter((s) => s && typeof s.letterAccuracy === 'object' && s.letterAccuracy !== null), + ); + return pool + .map((character) => { + const belief = beliefs[character]; + if (!belief) return null; + const attempts = belief.alpha + belief.beta - 2; // subtract Beta(1,1) prior + if (attempts < MIN_ATTEMPTS) return null; + const pError = betaPosteriorMeanError(belief); + if (pError < MIN_ERROR_TO_SHOW) return null; + return { character, pError }; + }) + .filter((entry): entry is { character: string; pError: number } => entry !== null) + .sort((a, b) => b.pError - a.pError) + .slice(0, MAX_FOCUS_CHARS); + }, [sessions, settings]); + + if (focus.length === 0) return null; + + return ( +
+ 🧠 +

+ Today's focus: the trainer will lean on your + current weak spots +

+ + {focus.map(({ character, pError }) => ( + + {character} + + ))} + +
+ ); +} diff --git a/src/hooks/useTrainingSession.ts b/src/hooks/useTrainingSession.ts index 372cc86..365db71 100644 --- a/src/hooks/useTrainingSession.ts +++ b/src/hooks/useTrainingSession.ts @@ -132,6 +132,10 @@ export function useTrainingSession({ useEffect(() => { hasActiveSessionRef.current = hasActiveSession; }, [hasActiveSession]); + const runtimeStatusRef = useRef(runtime.status); + useEffect(() => { + runtimeStatusRef.current = runtime.status; + }, [runtime.status]); // ── Refs ───────────────────────────────────────────────────────────── const isTrainingRef = useRef(false); @@ -225,10 +229,18 @@ export function useTrainingSession({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Runtime status captured before a visibility pause so it can be restored on + // return. Without it the UI reads "paused" until the loop reaches the next + // group boundary even though audio resumed. + const prePauseStatusRef = useRef<'playingGroup' | 'waitingForAnswer' | null>(null); + useEffect(() => { const handleHidden = (): void => { if (!hasActiveSessionRef.current) return; logTraining('visibility:hidden-pause'); + const status = runtimeStatusRef.current; + prePauseStatusRef.current = + status === 'playingGroup' || status === 'waitingForAnswer' ? status : null; setRuntimeStatus('paused', { pauseReason: 'Training paused while the page was hidden.' }); if (audio.audioContextRef.current?.state === 'suspended') { setRuntimeAudioStatus('suspended'); @@ -239,13 +251,24 @@ export function useTrainingSession({ if (!hasActiveSessionRef.current) return; const ctx = audio.audioContextRef.current; if (!ctx) return; + const restoreRuntimeStatus = (): void => { + const restored = prePauseStatusRef.current; + prePauseStatusRef.current = null; + if (restored && runtimeStatusRef.current === 'paused' && isTrainingRef.current) { + setRuntimeStatus(restored); + } + }; if (ctx.state === 'suspended') { void ctx.resume().then( - () => setRuntimeAudioStatus(ctx.state === 'running' ? 'running' : 'suspended'), + () => { + setRuntimeAudioStatus(ctx.state === 'running' ? 'running' : 'suspended'); + if (ctx.state === 'running') restoreRuntimeStatus(); + }, () => setRuntimeAudioStatus('suspended'), ); } else { setRuntimeAudioStatus(ctx.state === 'closed' ? 'closed' : 'running'); + if (ctx.state !== 'closed') restoreRuntimeStatus(); } }; diff --git a/src/lib/db/repositories/achievement.repository.ts b/src/lib/db/repositories/achievement.repository.ts index 9cb0af6..8202bc8 100644 --- a/src/lib/db/repositories/achievement.repository.ts +++ b/src/lib/db/repositories/achievement.repository.ts @@ -154,15 +154,23 @@ export class FirebaseAchievementRepository implements AchievementRepository { return null; } - const publicId = derivePublicIdFromUid(uid); const ref = doc(db, 'users', uid, 'meta', 'profile'); const snap = await getDoc(ref); const data = snap.exists() ? snap.data() : null; + // A stored publicId is permanent — share URLs depend on it. Only derive + // (and persist) one for users who don't have an ID yet. + const storedPublicId = + data && typeof data['publicId'] === 'number' && data['publicId'] > 0 + ? (data['publicId'] as number) + : null; + const publicId = storedPublicId ?? derivePublicIdFromUid(uid); const callSign = data && typeof data['callSign'] === 'string' && data['callSign'].trim().length > 0 ? data['callSign'].trim().toUpperCase() : undefined; - await setDoc(ref, { publicId, updatedAt: Date.now() }, { merge: true }); + if (storedPublicId === null) { + await setDoc(ref, { publicId, updatedAt: Date.now() }, { merge: true }); + } return { publicId, diff --git a/src/lib/streak.ts b/src/lib/streak.ts new file mode 100644 index 0000000..0e49e1c --- /dev/null +++ b/src/lib/streak.ts @@ -0,0 +1,139 @@ +import { localDateForTimestamp } from '@/lib/localDate'; + +/** + * Freeze-aware practice streaks. + * + * The streak is derived deterministically from practice-day history on every + * evaluation — nothing is stored, so it can never desync across devices. + * Freezes are streak insurance: one is earned for every + * {@link STREAK_FREEZE_EARN_DAYS} consecutive practiced days (holding at most + * {@link MAX_STORED_FREEZES}), and a missed day silently consumes one instead + * of breaking the streak. Losing a long streak to a single missed day is the + * top reason streak users abandon habit apps entirely. + * + * Trophy streaks (achievements module) intentionally stay strict — freezes + * only soften the day-to-day display, not the medals. + */ + +export const STREAK_FREEZE_EARN_DAYS = 7; +export const MAX_STORED_FREEZES = 2; +/** A lapsed streak is shown as "lost" (rather than silently reset) for this many days. */ +const LOST_VISIBILITY_DAYS = 7; + +export type StreakState = 'none' | 'safe' | 'at-risk' | 'lost'; + +export interface StreakStatus { + /** Current streak length in days (freeze-aware). */ + readonly days: number; + /** + * safe: practiced today. at-risk: streak alive but today not yet practiced. + * lost: a streak of 2+ days recently ended. none: nothing to show. + */ + readonly state: StreakState; + readonly freezesAvailable: number; + /** Freezes consumed within the currently displayed streak. */ + readonly freezesUsed: number; + /** Length of the streak that just ended (only for state 'lost'). */ + readonly lostStreakDays?: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +const toDayIndex = (date: string): number | null => { + const ts = Date.parse(`${date}T00:00:00.000Z`); + return Number.isFinite(ts) ? Math.floor(ts / DAY_MS) : null; +}; + +export function computeStreakStatus( + practiceDates: readonly string[], + now = Date.now(), +): StreakStatus { + const dayIndexes = [ + ...new Set( + practiceDates + .map(toDayIndex) + .filter((index): index is number => index !== null), + ), + ].sort((a, b) => a - b); + + const todayIndex = toDayIndex(localDateForTimestamp(now)); + if (todayIndex === null || dayIndexes.length === 0) { + return { days: 0, state: 'none', freezesAvailable: 0, freezesUsed: 0 }; + } + + let streak = 0; + let freezes = 0; + let freezesUsed = 0; + let daysSinceFreezeEarned = 0; + let previous: number | null = null; + + const earnOnPracticedDay = (): void => { + daysSinceFreezeEarned += 1; + if (daysSinceFreezeEarned >= STREAK_FREEZE_EARN_DAYS) { + freezes = Math.min(MAX_STORED_FREEZES, freezes + 1); + daysSinceFreezeEarned = 0; + } + }; + + for (const dayIndex of dayIndexes) { + if (dayIndex > todayIndex) break; // ignore future-dated entries + if (previous === null) { + streak = 1; + freezesUsed = 0; + daysSinceFreezeEarned = 0; + earnOnPracticedDay(); + previous = dayIndex; + continue; + } + const gap = dayIndex - previous - 1; + if (gap === 0) { + streak += 1; + } else if (gap > 0 && gap <= freezes) { + freezes -= gap; + freezesUsed += gap; + streak += 1; // frozen days keep the run alive but don't count as practice + } else { + streak = 1; + freezesUsed = 0; + daysSinceFreezeEarned = 0; + } + earnOnPracticedDay(); + previous = dayIndex; + } + + const lastPracticed = previous; + if (lastPracticed === null) { + return { days: 0, state: 'none', freezesAvailable: 0, freezesUsed: 0 }; + } + + if (lastPracticed === todayIndex) { + return { days: streak, state: 'safe', freezesAvailable: freezes, freezesUsed }; + } + + // Days fully missed between the last practice day and today (today itself is + // still in progress and never counts as missed). + const missed = todayIndex - lastPracticed - 1; + if (missed === 0) { + return { days: streak, state: 'at-risk', freezesAvailable: freezes, freezesUsed }; + } + if (missed <= freezes) { + return { + days: streak, + state: 'at-risk', + freezesAvailable: freezes - missed, + freezesUsed: freezesUsed + missed, + }; + } + + if (streak >= 2 && missed <= LOST_VISIBILITY_DAYS) { + return { + days: 0, + state: 'lost', + freezesAvailable: freezes, + freezesUsed: 0, + lostStreakDays: streak, + }; + } + + return { days: 0, state: 'none', freezesAvailable: freezes, freezesUsed: 0 }; +} diff --git a/src/store/providers/app-store-provider.tsx b/src/store/providers/app-store-provider.tsx index 198a5c5..9cc2405 100644 --- a/src/store/providers/app-store-provider.tsx +++ b/src/store/providers/app-store-provider.tsx @@ -1,5 +1,6 @@ 'use client'; +import { doc, getDoc, setDoc } from 'firebase/firestore'; import { createContext, useContext, useEffect, useRef } from 'react'; import type { ReactNode } from 'react'; import { useStore } from 'zustand'; @@ -12,6 +13,11 @@ import { FirebaseTrainingSettingsRepository, } from '@/lib/db/repositories'; import { setRemoteErrorSink } from '@/lib/errors/reporter'; +import { + exportAutoAdjustCounters, + importAutoAdjustCounters, + registerAutoAdjustSyncListener, +} from '@/lib/kochAutoAdjust'; import { readGroupRuntime, writeGroupRuntime, @@ -304,6 +310,61 @@ export function AppStoreProvider({ }; }, []); + // Mirror auto-level-adjust counters to Firestore. They live in localStorage + // for synchronous access, but without a cloud copy the "N of M sessions" + // progress silently resets on every new device while sessions themselves sync. + useEffect(() => { + if (!firebase?.db || !user?.id) { + registerAutoAdjustSyncListener(null); + return; + } + const db = firebase.db; + const uid = user.id; + let disposed = false; + let writeTimer: ReturnType | null = null; + + const docRef = (): unknown => doc(db, 'users', uid, 'meta', 'autoAdjust'); + + // Hydrate: cloud fills local gaps (local always wins). + void (async (): Promise => { + try { + const snap = await getDoc(docRef() as never); + if (disposed || !snap.exists()) return; + const data = snap.data() as { counters?: Record }; + if (data?.counters && typeof data.counters === 'object') { + importAutoAdjustCounters(data.counters); + } + } catch (error) { + console.debug('[app-store-provider] Auto-adjust counter hydration failed', error); + } + })(); + + registerAutoAdjustSyncListener((counters) => { + if (writeTimer) clearTimeout(writeTimer); + writeTimer = setTimeout(() => { + setDoc(docRef() as never, { counters, updatedAt: Date.now() }).catch((error: unknown) => { + console.debug('[app-store-provider] Auto-adjust counter sync failed', error); + }); + }, 2000); + }); + + // Push the current snapshot once so pre-existing local progress reaches the cloud. + const initial = exportAutoAdjustCounters(); + if (Object.keys(initial).length > 0) { + setDoc(docRef() as never, { counters: initial, updatedAt: Date.now() }, { merge: true }).catch( + (error: unknown) => { + console.debug('[app-store-provider] Initial auto-adjust counter sync failed', error); + }, + ); + } + + return (): void => { + disposed = true; + if (writeTimer) clearTimeout(writeTimer); + registerAutoAdjustSyncListener(null); + }; + }, [firebase, user]); + // Persist the group training runtime to sessionStorage so an in-progress session // survives a hard reload. On mount we restore any persisted snapshot (coerced to a // non-playing state by the machine, since audio cannot resume across a reload), then