From 21ab40f3234dbdc1512aa7652523982f0473eb3c Mon Sep 17 00:00:00 2001
From: Adam
Date: Thu, 22 Oct 2026 15:20:56 +0000
Subject: [PATCH] Keep a running speech when the timer's arrow keys hit either
end
Left on the first speech or Right on the last reset and paused the clock,
because navigation clamped the index but always started a fresh speech.
Asking for the speech already shown now changes nothing. The tools hub no
longer promises saved drafts: only the timer saves anything.
Co-Authored-By: Claude Opus 5.5
---
src/lib/free-tools/round-timer.ts | 7 ++++++-
src/pages/tools/ToolsHubPage.tsx | 2 +-
src/test/free-tools/round-timer.test.ts | 10 ++++++++++
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/lib/free-tools/round-timer.ts b/src/lib/free-tools/round-timer.ts
index b0e43c517..657f6624b 100644
--- a/src/lib/free-tools/round-timer.ts
+++ b/src/lib/free-tools/round-timer.ts
@@ -68,9 +68,14 @@ export function settlePrep(state: RoundState, now: number): RoundState {
return a === state.prep.a && b === state.prep.b ? state : { ...state, prep: { a, b } };
}
-/** Moves to another segment with a fresh, paused speech clock. Prep banks carry over. */
+/**
+ * Moves to another segment with a fresh, paused speech clock. Prep banks carry
+ * over. Asking for the segment already shown, including past either end of the
+ * round, changes nothing, so a stray arrow key never wipes a running speech.
+ */
export function goToSegment(state: RoundState, format: RoundFormat, index: number, now: number): RoundState {
const segmentIndex = Math.min(Math.max(0, index), format.segments.length - 1);
+ if (segmentIndex === state.segmentIndex) return state;
return { segmentIndex, speech: PAUSED, prep: pausePrep(state.prep, now) };
}
diff --git a/src/pages/tools/ToolsHubPage.tsx b/src/pages/tools/ToolsHubPage.tsx
index 3c0458de8..ba9ee6c0e 100644
--- a/src/pages/tools/ToolsHubPage.tsx
+++ b/src/pages/tools/ToolsHubPage.tsx
@@ -8,7 +8,7 @@ export default function ToolsHubPage() {
Tools for debate practice that work in any browser and need no account. Your rounds and drafts are saved in your browser, not on a server. Use them in a club session, on a projector in class, or embedded on your own site.
}
+ intro={Tools for debate practice that work in any browser and need no account. The timer saves your round in this browser, not on a server. Use them in a club session, on a projector in class, or embedded on your own site.
}
structuredData={hubStructuredData(FREE_TOOLS.map(({ route, name }) => ({ route, name })))}
tool={(
diff --git a/src/test/free-tools/round-timer.test.ts b/src/test/free-tools/round-timer.test.ts
index eb620b983..3d6f25ae1 100644
--- a/src/test/free-tools/round-timer.test.ts
+++ b/src/test/free-tools/round-timer.test.ts
@@ -110,6 +110,16 @@ describe('round timer', () => {
expect(prepRemainingMs(state, 'a', 10 * MIN)).toBe(210 * S);
});
+ it('keeps the running speech when navigation is already at the first or last speech', () => {
+ // The arrow keys can ask for a speech past either end; that must not wipe the clock.
+ const running = toggleSpeech(initialRoundState(FORMATS.pf), 0);
+ expect(goToSegment(running, FORMATS.pf, -1, 30 * S)).toBe(running);
+ const last = toggleSpeech(goToSegment(running, FORMATS.pf, 10, 0), 0);
+ expect(last.segmentIndex).toBe(10);
+ expect(goToSegment(last, FORMATS.pf, 11, 30 * S)).toBe(last);
+ expect(elapsedMs(goToSegment(last, FORMATS.pf, 11, 30 * S).speech, 30 * S)).toBe(30 * S);
+ });
+
it('restores a stored round only when it fits the format', () => {
const running = toggleSpeech(goToSegment(initialRoundState(FORMATS.pf), FORMATS.pf, 4, 0), 1234);
const stored = JSON.parse(JSON.stringify(running));