diff --git a/docs/TEACHING_PLAN.md b/docs/TEACHING_PLAN.md
index d40ee65..17628a5 100644
--- a/docs/TEACHING_PLAN.md
+++ b/docs/TEACHING_PLAN.md
@@ -43,15 +43,31 @@ reading-by-rhythm skill shows.
### Speed certificates
-Independent of stage progress, three certificates mark solid-copy milestones at
-the historic FCC licence speeds — **5, 13 and 20 WPM** (Novice / General /
-Amateur Extra). They are not one-session awards: a certificate is earned only
-after **three** qualifying group sessions of 125+ characters at ≥90% accuracy
-with a recorded character speed at or above the certificate speed.
-
-Unearned certificates stay out of the badge row; the panel explains the
-criteria and shows progress toward the next speed. Sessions without a recorded
-`charWpm` never count — speed must be known.
+Certificates form a **matrix of (stage × speed)** with the historic FCC exam
+speeds — **5, 13 and 20 WPM** (Novice / General / Amateur Extra). The historic
+tests presumed the complete alphabet, so the final row (full character set) is
+the true equivalent; it also unlocks trophy-case achievements
+(`solid-copy-5wpm` / `-13wpm` / `-20wpm`). Earlier rows give learners a
+certificate target at every stage of the ladder.
+
+A cell is earned by a **single session** — one sustained run, like the real
+exam, never an accumulation across sessions:
+
+- 100+ characters copied **correctly** from groups that were actually *played*
+ at or above the certificate speed,
+- ≥90% accuracy among those at-speed characters,
+- in a session at or above the stage's exit level.
+
+Because speed settings can be ranges, each group records the speed it was
+actually played at (`groupTimings[].charWpm`) — with variable speed, only the
+groups that met the certificate speed count toward the run. Legacy sessions
+without per-group speeds fall back to the session-level snapshot (the range
+minimum, conservative); history with no speed data never earns certificates —
+the plan does not guess.
+
+The panel shows best-attempt progress per cell (e.g. `87/100`), and the
+results screen calls out near-misses ("13 short — one more run?") so a failed
+attempt turns into the next session's goal.
## UI
@@ -59,8 +75,7 @@ criteria and shows progress toward the next speed. Sessions without a recorded
- overall plan progress (stages completed, progress bar),
- the active stage with its goal checklist and best copy-test accuracy,
-- the speed-certificate section (criteria, progress toward the next speed, and
- earned badges only).
+- the certificate row with earned/unearned state.
## Future extensions
diff --git a/lib/morseAudio.ts b/lib/morseAudio.ts
index 68471dd..3796ec2 100644
--- a/lib/morseAudio.ts
+++ b/lib/morseAudio.ts
@@ -118,14 +118,29 @@ export async function playMorseCodeControlled(
shouldStop: () => boolean,
outputNode?: AudioNode,
onStopReady?: (stop: () => void) => void,
-): Promise<{ durationSec: number; startTime: number; stop: () => void }> {
- if (shouldStop()) return { durationSec: 0, startTime: ctx.currentTime, stop: () => {} };
-
- await ensureContext(ctx);
-
+): Promise<{
+ durationSec: number;
+ startTime: number;
+ stop: () => void;
+ /** Character speed this playback actually used (ranges are sampled per call). */
+ resolvedCharWpm: number;
+ /** Effective (Farnsworth) speed this playback actually used. */
+ resolvedEffectiveWpm: number;
+}> {
// Determine timing based on Farnsworth (supports random ranges)
const resolvedCharWpm = resolveCharWpm(settings);
const resolvedEffWpm = resolveEffectiveWpm(settings, resolvedCharWpm);
+
+ if (shouldStop())
+ return {
+ durationSec: 0,
+ startTime: ctx.currentTime,
+ stop: () => {},
+ resolvedCharWpm,
+ resolvedEffectiveWpm: resolvedEffWpm,
+ };
+
+ await ensureContext(ctx);
const extraWordSpaceMultiplier = clampExtraSpacingMultiplier(settings.extraWordSpaceMultiplier);
const dotChar = 1.2 / resolvedCharWpm; // seconds
@@ -256,7 +271,13 @@ export async function playMorseCodeControlled(
// Return duration and the actual start time (AudioContext) used for scheduling,
// so callers can compute precise end time (startTime + durationSec) for reaction timing.
- return { durationSec: currentTime - startTime, startTime, stop };
+ return {
+ durationSec: currentTime - startTime,
+ startTime,
+ stop,
+ resolvedCharWpm,
+ resolvedEffectiveWpm: resolvedEffWpm,
+ };
}
diff --git a/lib/sessionPersistence.ts b/lib/sessionPersistence.ts
index c89aebd..07a4b2f 100644
--- a/lib/sessionPersistence.ts
+++ b/lib/sessionPersistence.ts
@@ -111,17 +111,21 @@ export const normalizeSession = (raw: unknown, opts?: { docId?: string }): Sessi
: sent.length > 0 && sent === received;
return { sent, received, correct };
});
- const groupTimings: Array<{ timeToCompleteMs: number; perCharMs?: number }> = (() => {
- if (Array.isArray(rawObj['groupTimings'])) {
- return (rawObj['groupTimings'] as unknown[]).map((t: any) => ({
- timeToCompleteMs: Math.max(0, Number(t?.timeToCompleteMs) || 0),
- ...(typeof t?.perCharMs === 'number' && isFinite(t.perCharMs) && t.perCharMs >= 0
- ? { perCharMs: Number(t.perCharMs) }
- : {}),
- }));
- }
- return groups.map(() => ({ timeToCompleteMs: 0 }));
- })();
+ const groupTimings: Array<{ timeToCompleteMs: number; perCharMs?: number; charWpm?: number }> =
+ (() => {
+ if (Array.isArray(rawObj['groupTimings'])) {
+ return (rawObj['groupTimings'] as unknown[]).map((t: any) => ({
+ timeToCompleteMs: Math.max(0, Number(t?.timeToCompleteMs) || 0),
+ ...(typeof t?.perCharMs === 'number' && isFinite(t.perCharMs) && t.perCharMs >= 0
+ ? { perCharMs: Number(t.perCharMs) }
+ : {}),
+ ...(typeof t?.charWpm === 'number' && isFinite(t.charWpm) && t.charWpm >= 1
+ ? { charWpm: Number(t.charWpm) }
+ : {}),
+ }));
+ }
+ return groups.map(() => ({ timeToCompleteMs: 0 }));
+ })();
const safeAccuracy = (() => {
if (typeof rawObj['accuracy'] === 'number' && isFinite(rawObj['accuracy'] as number))
return rawObj['accuracy'] as number;
diff --git a/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx b/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx
index 9109367..2eabf21 100644
--- a/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx
+++ b/src/__tests__/components/ui/forms/sections/ToneEnvelopeSection.test.tsx
@@ -40,6 +40,8 @@ describe('ToneEnvelopeSection', () => {
durationSec: 0.1,
startTime: 0,
stop: jest.fn(),
+ resolvedCharWpm: 20,
+ resolvedEffectiveWpm: 20,
});
class MockAudioContext {
public readonly close = jest.fn().mockResolvedValue(undefined);
diff --git a/src/__tests__/hooks/useTrainingAudio.test.ts b/src/__tests__/hooks/useTrainingAudio.test.ts
index ea4976c..6ae1992 100644
--- a/src/__tests__/hooks/useTrainingAudio.test.ts
+++ b/src/__tests__/hooks/useTrainingAudio.test.ts
@@ -31,6 +31,8 @@ describe('useTrainingAudio', () => {
durationSec: 0.5,
startTime: 0,
stop: jest.fn(),
+ resolvedCharWpm: 20,
+ resolvedEffectiveWpm: 20,
});
});
@@ -50,7 +52,7 @@ describe('useTrainingAudio', () => {
const playback = await result.current.playMorse('KM', 0);
- expect(playback).toEqual({ status: 'played', durationSec: 0.5 });
+ expect(playback).toEqual({ status: 'played', durationSec: 0.5, charWpm: 20, effectiveWpm: 20 });
expect(mockPlayMorse).toHaveBeenCalled();
});
@@ -68,7 +70,7 @@ describe('useTrainingAudio', () => {
const stop = jest.fn();
onStopReady?.(stop);
capturedStop = stop;
- return { durationSec: 0.5, startTime: 0, stop };
+ return { durationSec: 0.5, startTime: 0, stop, resolvedCharWpm: 20, resolvedEffectiveWpm: 20 };
},
);
diff --git a/src/__tests__/hooks/useTrainingSession.test.tsx b/src/__tests__/hooks/useTrainingSession.test.tsx
index 1448868..6b71884 100644
--- a/src/__tests__/hooks/useTrainingSession.test.tsx
+++ b/src/__tests__/hooks/useTrainingSession.test.tsx
@@ -293,7 +293,7 @@ describe('useTrainingSession', () => {
it('waits for scheduled playback duration before unlocking input', async () => {
const audio = createMockAudio();
- jest.mocked(audio.playMorse).mockResolvedValue({ status: 'played', durationSec: 2.5 });
+ jest.mocked(audio.playMorse).mockResolvedValue({ status: 'played', durationSec: 2.5, charWpm: 20, effectiveWpm: 20 });
mockUseTrainingAudio.mockReturnValue(audio);
const { result } = renderSessionHook();
@@ -888,7 +888,7 @@ describe('useTrainingSession', () => {
jest.mocked(audio.playMorse).mockImplementation(
() =>
new Promise((resolve) => {
- playResolvers.push(() => resolve({ status: 'played', durationSec: 0 }));
+ playResolvers.push(() => resolve({ status: 'played', durationSec: 0, charWpm: 20, effectiveWpm: 20 }));
}),
);
mockUseTrainingAudio.mockReturnValue(audio);
diff --git a/src/__tests__/lib/curriculum/speedCertificates.test.ts b/src/__tests__/lib/curriculum/speedCertificates.test.ts
new file mode 100644
index 0000000..e9f5c9f
--- /dev/null
+++ b/src/__tests__/lib/curriculum/speedCertificates.test.ts
@@ -0,0 +1,149 @@
+import {
+ attemptedStageForLevel,
+ buildTeachingPlan,
+ evaluateSessionSpeedRun,
+ evaluateSpeedCertificateMatrix,
+} from '@/lib/curriculum';
+import type { SessionResult, SessionGroup, SessionTiming } from '@/types';
+
+/** Build a session from parallel groups/timings; defaults to a full-alphabet level. */
+const makeSession = ({
+ timestamp,
+ groups,
+ timings,
+ kochLevel = 40,
+ sessionCharWpm,
+ mode,
+}: {
+ readonly timestamp: number;
+ readonly groups: readonly SessionGroup[];
+ readonly timings: readonly SessionTiming[];
+ readonly kochLevel?: number;
+ readonly sessionCharWpm?: number;
+ readonly mode?: 'group' | 'echo' | 'chase';
+}): SessionResult => ({
+ date: '2026-07-01',
+ timestamp,
+ startedAt: timestamp - 60_000,
+ finishedAt: timestamp,
+ groups: [...groups],
+ groupTimings: [...timings],
+ accuracy: 1,
+ letterAccuracy: {},
+ alphabetSize: 26,
+ avgResponseMs: 1500,
+ totalChars: groups.reduce((sum, g) => sum + g.sent.length, 0),
+ effectiveAlphabetSize: 26,
+ score: 100,
+ kochLevel,
+ ...(sessionCharWpm !== undefined ? { charWpm: sessionCharWpm } : {}),
+ ...(mode !== undefined ? { mode } : {}),
+});
+
+const correctGroup = (sent: string): SessionGroup => ({ sent, received: sent, correct: true });
+
+/** N correct 5-char groups at the given per-group speed. */
+const groupsAtSpeed = (
+ count: number,
+ wpm: number,
+): { groups: SessionGroup[]; timings: SessionTiming[] } => ({
+ groups: Array.from({ length: count }, () => correctGroup('KMURE')),
+ timings: Array.from({ length: count }, () => ({ timeToCompleteMs: 1000, charWpm: wpm })),
+});
+
+describe('evaluateSessionSpeedRun', () => {
+ it('counts only characters from groups played at or above the certificate speed', () => {
+ const fast = groupsAtSpeed(10, 20); // 50 chars at 20 WPM
+ const slow = groupsAtSpeed(10, 10); // 50 chars at 10 WPM
+ const session = makeSession({
+ timestamp: 1,
+ groups: [...fast.groups, ...slow.groups],
+ timings: [...fast.timings, ...slow.timings],
+ });
+ const at20 = evaluateSessionSpeedRun(session, 20);
+ expect(at20.totalChars).toBe(50);
+ const at10 = evaluateSessionSpeedRun(session, 10);
+ expect(at10.totalChars).toBe(100);
+ });
+
+ it('requires 100 correct at-speed characters within the single session', () => {
+ const nineteen = groupsAtSpeed(19, 20); // 95 chars — just short
+ const short = makeSession({ timestamp: 1, groups: nineteen.groups, timings: nineteen.timings });
+ expect(evaluateSessionSpeedRun(short, 20).achieved).toBe(false);
+
+ const twenty = groupsAtSpeed(20, 20); // 100 chars
+ const enough = makeSession({ timestamp: 2, groups: twenty.groups, timings: twenty.timings });
+ expect(evaluateSessionSpeedRun(enough, 20).achieved).toBe(true);
+ });
+
+ it('requires at-speed accuracy, not just volume', () => {
+ const { groups, timings } = groupsAtSpeed(30, 20); // 150 chars
+ const flawed = groups.map((g, i) =>
+ i < 10 ? { sent: g.sent, received: 'XXXXX', correct: false } : g,
+ );
+ const session = makeSession({ timestamp: 1, groups: flawed, timings });
+ const run = evaluateSessionSpeedRun(session, 20);
+ expect(run.correctChars).toBe(100);
+ expect(run.accuracy).toBeCloseTo(100 / 150);
+ expect(run.achieved).toBe(false); // 67% at-speed accuracy < 90%
+ });
+
+ it('falls back to the session-level speed snapshot for legacy sessions', () => {
+ const groups = Array.from({ length: 25 }, () => correctGroup('KMURE'));
+ const timings = groups.map(() => ({ timeToCompleteMs: 1000 })); // no per-group speed
+ const legacy = makeSession({ timestamp: 1, groups, timings, sessionCharWpm: 15 });
+ expect(evaluateSessionSpeedRun(legacy, 13).achieved).toBe(true);
+ expect(evaluateSessionSpeedRun(legacy, 20).totalChars).toBe(0);
+ });
+});
+
+describe('evaluateSpeedCertificateMatrix', () => {
+ const plan = buildTeachingPlan();
+
+ it('credits the stage bracket the session level reaches, and all below it', () => {
+ const { groups, timings } = groupsAtSpeed(25, 13);
+ const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 12 })];
+ const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
+ // Level 12 covers stages with exit level <= 12 (stages 1 and 2)
+ expect(matrix.rows[0]!.cells.find((c) => c.speed === 13)!.earned).toBe(true);
+ expect(matrix.rows[1]!.cells.find((c) => c.speed === 13)!.earned).toBe(true);
+ expect(matrix.rows[2]!.cells.find((c) => c.speed === 13)!.earned).toBe(false);
+ // Faster speeds not earned
+ expect(matrix.rows[0]!.cells.find((c) => c.speed === 20)!.earned).toBe(false);
+ });
+
+ it('tracks the best near-miss attempt for progress display', () => {
+ const { groups, timings } = groupsAtSpeed(15, 20); // 75 correct chars — short of 100
+ const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 40 })];
+ const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
+ const lastRow = matrix.rows[matrix.rows.length - 1]!;
+ const cell = lastRow.cells.find((c) => c.speed === 20)!;
+ expect(cell.earned).toBe(false);
+ expect(cell.bestCorrectChars).toBe(75);
+ });
+
+ it('marks full-alphabet certificates from the final stage row', () => {
+ const { groups, timings } = groupsAtSpeed(20, 20);
+ const sessions = [makeSession({ timestamp: 1, groups, timings, kochLevel: 40 })];
+ const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
+ expect(matrix.fullAlphabetEarnedSpeeds).toEqual([5, 13, 20]);
+ });
+
+ it('ignores echo/chase sessions', () => {
+ const { groups, timings } = groupsAtSpeed(20, 20);
+ const sessions = [makeSession({ timestamp: 1, groups, timings, mode: 'echo' })];
+ const matrix = evaluateSpeedCertificateMatrix(sessions, plan);
+ expect(matrix.earnedCount).toBe(0);
+ });
+});
+
+describe('attemptedStageForLevel', () => {
+ const plan = buildTeachingPlan();
+
+ it('returns the highest stage whose exit level is reached', () => {
+ expect(attemptedStageForLevel(plan, 3)).toBeNull(); // below first exit level (5)
+ expect(attemptedStageForLevel(plan, 5)!.index).toBe(0);
+ expect(attemptedStageForLevel(plan, 12)!.index).toBe(1);
+ expect(attemptedStageForLevel(plan, 40)!.index).toBe(plan.length - 1);
+ });
+});
diff --git a/src/__tests__/lib/curriculum/teachingPlan.test.ts b/src/__tests__/lib/curriculum/teachingPlan.test.ts
index b157b5c..474b331 100644
--- a/src/__tests__/lib/curriculum/teachingPlan.test.ts
+++ b/src/__tests__/lib/curriculum/teachingPlan.test.ts
@@ -118,36 +118,10 @@ describe('evaluateTeachingPlan', () => {
expect(quality.current).toBe(0);
});
- it('awards speed certificates only after repeated solid-copy sessions at speed', () => {
- const oneSession = [
- makeSession({ timestamp: 1, kochLevel: 3, charWpm: 15, accuracy: 0.94, totalChars: 130 }),
- ];
- const afterOne = evaluateTeachingPlan(oneSession, 3);
- const byWpmAfterOne = new Map(afterOne.certificates.map((c) => [c.wpm, c]));
- expect(byWpmAfterOne.get(5)!.earned).toBe(false);
- expect(byWpmAfterOne.get(5)!.current).toBe(1);
- expect(byWpmAfterOne.get(13)!.current).toBe(1);
- expect(byWpmAfterOne.get(20)!.current).toBe(0);
-
- const threeSessions = [
- makeSession({ timestamp: 1, kochLevel: 3, charWpm: 15, accuracy: 0.94, totalChars: 130 }),
- makeSession({ timestamp: 2, kochLevel: 3, charWpm: 15, accuracy: 0.91, totalChars: 130 }),
- makeSession({ timestamp: 3, kochLevel: 3, charWpm: 14, accuracy: 0.93, totalChars: 140 }),
- ];
- const progress = evaluateTeachingPlan(threeSessions, 3);
- const byWpm = new Map(progress.certificates.map((c) => [c.wpm, c]));
- expect(byWpm.get(5)!.earned).toBe(true);
- expect(byWpm.get(13)!.earned).toBe(true);
- expect(byWpm.get(20)!.earned).toBe(false);
- expect(byWpm.get(5)!.sessionTimestamp).toBe(3);
- });
-
- it('does not award certificates without volume or speed data', () => {
- const sessions = [
- makeSession({ timestamp: 1, kochLevel: 3, accuracy: 0.99, totalChars: 500 }), // no charWpm
- makeSession({ timestamp: 2, kochLevel: 3, charWpm: 25, accuracy: 0.99, totalChars: 50 }), // too short
- ];
- const progress = evaluateTeachingPlan(sessions, 3);
- expect(progress.certificates.every((c) => !c.earned && c.current === 0)).toBe(true);
+ it('exposes the speed-certificate matrix with one row per stage', () => {
+ const progress = evaluateTeachingPlan([], 1);
+ expect(progress.certificates.rows.length).toBe(progress.stages.length);
+ expect(progress.certificates.earnedCount).toBe(0);
+ expect(progress.certificates.rows[0]!.cells.map((c) => c.speed)).toEqual([5, 13, 20]);
});
});
diff --git a/src/components/features/training/TeachingPlanPanel.tsx b/src/components/features/training/TeachingPlanPanel.tsx
index b960483..3319bb4 100644
--- a/src/components/features/training/TeachingPlanPanel.tsx
+++ b/src/components/features/training/TeachingPlanPanel.tsx
@@ -2,14 +2,8 @@
import React, { useMemo, useState } from 'react';
-import {
- CERTIFICATE_MIN_CHARS,
- CERTIFICATE_SESSIONS_TARGET,
- QUALITY_ACCURACY,
- buildTeachingPlan,
- evaluateTeachingPlan,
-} from '@/lib/curriculum';
-import type { SpeedCertificateProgress, StageProgress } from '@/lib/curriculum';
+import { buildTeachingPlan, evaluateTeachingPlan } from '@/lib/curriculum';
+import type { SpeedCertificateMatrix, StageProgress } from '@/lib/curriculum';
import type { SessionResult, TrainingSettings } from '@/types';
export interface TeachingPlanPanelProps {
@@ -159,72 +153,121 @@ export function TeachingPlanPanel({ sessions, settings }: TeachingPlanPanelProps
) : null}
-
Speed certificates
- {earned.map((cert) => ( - - 🏅 {cert.wpm} WPM - - ))} - {earned.length > 0 ? ( + + {matrix.earnedCount}/{matrix.totalCells} earned + + {matrix.earnedCount > 0 ? ( ) : null}- Solid-copy milestones at the historic FCC exam speeds — 5, 13, and 20 WPM (Novice / - General / Extra). Earn one by completing {CERTIFICATE_SESSIONS_TARGET} sessions of{' '} - {CERTIFICATE_MIN_CHARS}+ characters at ≥{accuracyPct}% accuracy with character speed at - that rate or faster. +
| Stage | + {matrix.rows[0]?.cells.map((cell) => ( ++ {cell.speed} WPM + | + ))} +
|---|---|
| + {row.stage.index === lastIndex ? '★ Full set' : `Stage ${row.stage.index + 1}`} + | + {row.cells.map((cell) => ( ++ {cell.earned ? ( + + 🏅 + + ) : cell.bestCorrectChars > 0 ? ( + + {cell.bestCorrectChars}/{cell.targetChars} + + ) : ( + — + )} + | + ))} +
+ Earned in a single session: {matrix.rows[0]?.cells[0]?.targetChars ?? 100}+ characters + copied correctly from groups played at the certificate speed, ≥90% accuracy at speed. With + variable speed only the groups that met the speed count.
- {nextTarget !== undefined ? ( -- Next: {nextTarget.wpm} WPM — {nextTarget.current}/{nextTarget.target} qualifying - sessions -
- ) : ( -- All speed certificates earned. -
- )}+ {certLine.emoji} + {certLine.text} +
+ ) : null} {goalLine ? (
🎯
diff --git a/src/hooks/useTrainingAudio.ts b/src/hooks/useTrainingAudio.ts
index ae0d5c8..82f206c 100644
--- a/src/hooks/useTrainingAudio.ts
+++ b/src/hooks/useTrainingAudio.ts
@@ -14,7 +14,13 @@ import { pickTrainingToneHz } from '@/lib/trainingSessionPlayback';
import type { TrainingSettings } from '@/types';
export type TrainingAudioPlaybackResult =
- | { readonly status: 'played'; readonly durationSec: number }
+ | {
+ readonly status: 'played';
+ readonly durationSec: number;
+ /** Character speed this group was actually played at (ranges sample per group). */
+ readonly charWpm: number;
+ readonly effectiveWpm: number;
+ }
| { readonly status: 'skippedBecauseAborted' }
| { readonly status: 'suspended' }
| { readonly status: 'failed'; readonly message: string };
@@ -125,7 +131,7 @@ export function useTrainingAudio(
resumeAudioContextFromUserGesture(ctx);
}
const bandConditions = ensureBandConditions(ctx);
- const { durationSec, stop } = await playMorseCodeControlled(
+ const { durationSec, stop, resolvedCharWpm, resolvedEffectiveWpm } = await playMorseCodeControlled(
ctx,
text,
{
@@ -151,7 +157,12 @@ export function useTrainingAudio(
},
);
currentStopRef.current = stop;
- return { status: 'played', durationSec };
+ return {
+ status: 'played',
+ durationSec,
+ charWpm: resolvedCharWpm,
+ effectiveWpm: resolvedEffectiveWpm,
+ };
} catch (error) {
console.error('[TrainingAudio] Playback error:', error);
return {
diff --git a/src/hooks/useTrainingSession.ts b/src/hooks/useTrainingSession.ts
index 365db71..c2144a1 100644
--- a/src/hooks/useTrainingSession.ts
+++ b/src/hooks/useTrainingSession.ts
@@ -175,6 +175,7 @@ export function useTrainingSession({
const groupStartAtRef = useRef