From 132e792040728e38101ee863f437fbb2ecda9a21 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:28:23 +0000 Subject: [PATCH 1/8] Move the Sparring round's moves into @debatelab/core The store's turn, clock, judging and saved-row logic becomes pure functions in packages/core/src/sparring/session.ts, so the app keeps rounds the same way. The website store and SparringPage import them back unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/sparring/session.ts | 270 ++++++++++++++++++++++++++ src/lib/sparring/session.ts | 1 + src/lib/stores/sparring-store.ts | 263 ++++--------------------- src/pages/SparringPage.tsx | 6 +- src/test/sparring/session.test.ts | 111 +++++++++++ 5 files changed, 422 insertions(+), 229 deletions(-) create mode 100644 packages/core/src/sparring/session.ts create mode 100644 src/lib/sparring/session.ts create mode 100644 src/test/sparring/session.test.ts diff --git a/packages/core/src/sparring/session.ts b/packages/core/src/sparring/session.ts new file mode 100644 index 000000000..556885589 --- /dev/null +++ b/packages/core/src/sparring/session.ts @@ -0,0 +1,270 @@ +import { z } from 'zod'; +import { + SparringScorecardSchema, + SparringSpeechSchema, + type SparringDifficulty, + type SparringFormat, + type SparringScorecard, + type SparringSide, + type SparringSpeech, +} from '../ai/schemas/sparring'; +import { buildSparringTurns, type SparringTurn } from './formats'; + +/** + * A Sparring round and its moves: whose turn it is, the speaking clock, the + * speeches, and judging. The website's sparring store and the app both keep + * a round with these. Each move returns the same object when nothing changes, + * and null when the move is not allowed. + */ + +export type SparringPhase = 'round' | 'judging' | 'complete'; + +export interface SparringSession { + id: string; + motion: string; + userSide: SparringSide; + format: SparringFormat; + difficulty: SparringDifficulty; + turns: SparringTurn[]; + speeches: SparringSpeech[]; + currentTurnIndex: number; + phase: SparringPhase; + draft: string; + timerRemainingSeconds: number; + timerRunning: boolean; + timerDeadline: number | null; + scorecard?: SparringScorecard; + /** Debate the round attaches to once judged; null for one-off rounds. */ + debateId?: string | null; + /** A voice round: the student speaks and the opponent answers aloud. */ + voice?: boolean; + createdAt: number; + updatedAt: number; +} + +export interface StartSparringInput { + motion: string; + userSide: SparringSide; + format: SparringFormat; + difficulty: SparringDifficulty; + debateId?: string | null; + voice?: boolean; +} + +export const MAX_SPARRING_SPEECH_CHARS = 6_000; +export const MAX_SAVED_SPARRING_SESSIONS = 50; + +export function currentSparringTurn(session: SparringSession): SparringTurn | undefined { + return session.turns[session.currentTurnIndex]; +} + +/** The clock for a turn: the student's turns start running at once; the opponent's have none. */ +export function timerForTurn(turn: SparringTurn | undefined, now: number) { + const duration = turn?.timeLimitSeconds ?? 0; + const shouldRun = turn?.speaker === 'user'; + return { + timerRemainingSeconds: duration, + timerRunning: shouldRun, + timerDeadline: shouldRun ? now + duration * 1000 : null, + }; +} + +function advanceSession(session: SparringSession, now: number): SparringSession { + const nextIndex = session.currentTurnIndex + 1; + const nextTurn = session.turns[nextIndex]; + if (!nextTurn) { + return { + ...session, + currentTurnIndex: session.turns.length, + phase: 'judging', + draft: '', + timerRemainingSeconds: 0, + timerRunning: false, + timerDeadline: null, + updatedAt: now, + }; + } + + return { + ...session, + currentTurnIndex: nextIndex, + draft: '', + ...timerForTurn(nextTurn, now), + updatedAt: now, + }; +} + +export function startSparringSession(input: StartSparringInput, id: string, now: number): SparringSession { + const turns = buildSparringTurns(input.format, input.userSide); + return { + id, + motion: input.motion.trim(), + userSide: input.userSide, + format: input.format, + difficulty: input.difficulty, + turns, + speeches: [], + currentTurnIndex: 0, + phase: 'round', + draft: '', + ...timerForTurn(turns[0], now), + debateId: input.debateId ?? null, + ...(input.voice ? { voice: true } : {}), + createdAt: now, + updatedAt: now, + }; +} + +/** Recounts the running clock from its deadline. The same object while the whole seconds left have not changed. */ +export function tickSparringTimer(session: SparringSession, now: number): SparringSession { + if (!session.timerRunning || session.timerDeadline === null) return session; + const remaining = Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)); + if (remaining === session.timerRemainingSeconds) return session; + return { + ...session, + timerRemainingSeconds: remaining, + timerRunning: remaining > 0, + timerDeadline: remaining > 0 ? session.timerDeadline : null, + }; +} + +export function pauseSparringTimer(session: SparringSession, now: number): SparringSession { + if (!session.timerRunning || session.timerDeadline === null) return session; + const remaining = Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)); + return { ...session, timerRemainingSeconds: remaining, timerRunning: false, timerDeadline: null }; +} + +export function resumeSparringTimer(session: SparringSession, now: number): SparringSession { + const turn = currentSparringTurn(session); + if (session.phase !== 'round' || turn?.speaker !== 'user' || session.timerRunning || session.timerRemainingSeconds <= 0) return session; + return { ...session, timerRunning: true, timerDeadline: now + session.timerRemainingSeconds * 1000 }; +} + +/** Starts the student's turn again with its full time. */ +export function resetSparringTurnTimer(session: SparringSession, now: number): SparringSession { + const turn = currentSparringTurn(session); + if (turn?.speaker !== 'user') return session; + return { ...session, ...timerForTurn(turn, now) }; +} + +/** + * Records the student's speech and moves to the next turn. Null when it is + * not the student's turn, the text is empty or too long, or the time is up. + */ +export function submitSparringSpeech( + session: SparringSession, + content: string, + options: { now: number; createId: () => string; delivery?: SparringSpeech['delivery'] | null }, +): SparringSession | null { + const { now, delivery = null } = options; + const turn = currentSparringTurn(session); + const cleanContent = content.trim(); + const deadlinePassed = session.timerDeadline !== null && session.timerDeadline <= now; + if (session.phase !== 'round' || turn?.speaker !== 'user' || !cleanContent || cleanContent.length > MAX_SPARRING_SPEECH_CHARS || session.timerRemainingSeconds <= 0 || deadlinePassed) return null; + const remaining = session.timerRunning && session.timerDeadline !== null + ? Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)) + : session.timerRemainingSeconds; + const speech: SparringSpeech = { + id: options.createId(), + turnId: turn.id, + speaker: 'user', + side: turn.side, + role: turn.label, + content: cleanContent, + createdAt: now, + timeLimitSeconds: turn.timeLimitSeconds, + timeUsedSeconds: Math.max(0, turn.timeLimitSeconds - remaining), + ...(delivery ? { delivery } : {}), + }; + return advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); +} + +/** Records the opponent's speech for the current turn, once. Null for any other turn. */ +export function appendSparringAiSpeech(session: SparringSession, speech: SparringSpeech, now: number): SparringSession | null { + const turn = currentSparringTurn(session); + const alreadyRecorded = session.speeches.some((item) => item.id === speech.id || item.turnId === speech.turnId); + if (session.phase !== 'round' || turn?.speaker !== 'ai' || speech.turnId !== turn.id || alreadyRecorded) return null; + return advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); +} + +/** The judged round. Null unless the round is waiting for its judge. */ +export function completeSparringSession(session: SparringSession, scorecard: SparringScorecard, now: number): (SparringSession & { scorecard: SparringScorecard }) | null { + if (session.phase !== 'judging') return null; + return { + ...session, + phase: 'complete', + scorecard, + timerRemainingSeconds: 0, + timerRunning: false, + timerDeadline: null, + updatedAt: now, + }; +} + +/** How long the opponent's typed speech would take aloud, at 140 words a minute. */ +export function estimateAiTimeUsedSeconds(content: string, limit: number) { + const words = content.trim() ? content.trim().split(/\s+/).length : 0; + return Math.min(limit, Math.max(1, Math.round((words / 140) * 60))); +} + +/** One list of saved rounds, the newest copy of each, newest first. */ +export function mergeSparringSessions(local: SparringSession[], remote: SparringSession[]): SparringSession[] { + const byId = new Map(); + for (const session of [...local, ...remote]) { + const current = byId.get(session.id); + if (!current || current.updatedAt < session.updatedAt) byId.set(session.id, session); + } + return [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SAVED_SPARRING_SESSIONS); +} + +/** The sparring_sessions columns a saved round is read from. */ +export const SPARRING_SESSION_COLUMNS = 'id,motion,user_side,format,difficulty,turns,final_evaluation,status,debate_id,created_at,updated_at'; + +const SavedRowsSchema = z.array(z.object({ + id: z.string(), + motion: z.string(), + user_side: z.enum(['pro', 'con']), + format: z.enum(['standard', 'ld', 'pf']).catch('standard'), + difficulty: z.enum(['easy', 'medium', 'hard']).catch('medium'), + turns: z.unknown(), + final_evaluation: z.unknown(), + status: z.string(), + debate_id: z.string().nullable().optional(), + created_at: z.string(), + updated_at: z.string(), +})); + +/** Judged rounds from sparring_sessions rows. Rows that are unfinished or do not check out are left out. */ +export function parseSavedSparringRows(value: unknown): SparringSession[] { + const parsedRows = SavedRowsSchema.safeParse(value); + if (!parsedRows.success) return []; + + return parsedRows.data.flatMap((row): SparringSession[] => { + const speeches = z.array(SparringSpeechSchema).max(12).safeParse(row.turns); + const scorecard = SparringScorecardSchema.safeParse(row.final_evaluation); + if (!speeches.success || !scorecard.success || row.status !== 'complete') return []; + + const turns = buildSparringTurns(row.format, row.user_side); + const createdAt = new Date(row.created_at).getTime(); + const updatedAt = new Date(row.updated_at).getTime(); + return [{ + id: row.id, + motion: row.motion, + userSide: row.user_side, + format: row.format, + difficulty: row.difficulty, + turns, + speeches: speeches.data, + currentTurnIndex: turns.length, + phase: 'complete', + draft: '', + timerRemainingSeconds: 0, + timerRunning: false, + timerDeadline: null, + scorecard: scorecard.data, + debateId: row.debate_id ?? null, + createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), + updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(), + }]; + }); +} diff --git a/src/lib/sparring/session.ts b/src/lib/sparring/session.ts new file mode 100644 index 000000000..8e38d6639 --- /dev/null +++ b/src/lib/sparring/session.ts @@ -0,0 +1 @@ +export * from '@debatelab/core/sparring/session'; diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts index 46956d125..58b7ed1ea 100644 --- a/src/lib/stores/sparring-store.ts +++ b/src/lib/stores/sparring-store.ts @@ -7,51 +7,33 @@ import { SparringSpeakerSchema, SparringSpeechSchema, SparringTurnSchema, - type SparringDifficulty, - type SparringFormat, type SparringScorecard, - type SparringSide, type SparringSpeech, } from '@/lib/ai/schemas/sparring'; -import { buildSparringTurns, type SparringTurn } from '@/lib/sparring/formats'; +import { buildSparringTurns } from '@/lib/sparring/formats'; +import { + appendSparringAiSpeech, + completeSparringSession, + MAX_SAVED_SPARRING_SESSIONS, + mergeSparringSessions as mergeSessions, + parseSavedSparringRows as parseSavedRows, + pauseSparringTimer, + resetSparringTurnTimer, + resumeSparringTimer, + SPARRING_SESSION_COLUMNS, + startSparringSession, + submitSparringSpeech, + tickSparringTimer, + type SparringSession, + type StartSparringInput, +} from '@/lib/sparring/session'; import { captureProductEvent } from '@/lib/observability'; import type { SpeechDelivery } from '@/lib/voice/delivery'; import { useDebateStore } from './debate-store'; import { useDebateContextStore } from './debate-context-store'; -export type SparringPhase = 'round' | 'judging' | 'complete'; - -export interface SparringSession { - id: string; - motion: string; - userSide: SparringSide; - format: SparringFormat; - difficulty: SparringDifficulty; - turns: SparringTurn[]; - speeches: SparringSpeech[]; - currentTurnIndex: number; - phase: SparringPhase; - draft: string; - timerRemainingSeconds: number; - timerRunning: boolean; - timerDeadline: number | null; - scorecard?: SparringScorecard; - /** Debate the round attaches to once judged; null for one-off rounds. */ - debateId?: string | null; - /** A voice round: the student speaks and the opponent answers aloud. */ - voice?: boolean; - createdAt: number; - updatedAt: number; -} - -interface StartSessionInput { - motion: string; - userSide: SparringSide; - format: SparringFormat; - difficulty: SparringDifficulty; - debateId?: string | null; - voice?: boolean; -} +// The round and its moves live in @debatelab/core so the app keeps rounds the same way. +export type { SparringPhase, SparringSession } from '@/lib/sparring/session'; interface SparringStoreState { ownerUserId: string | null; @@ -63,7 +45,7 @@ interface SparringStoreState { historyComplete: boolean; beginWorkspaceSession: () => void; endWorkspaceSession: () => void; - startSession: (input: StartSessionInput) => SparringSession; + startSession: (input: StartSparringInput) => SparringSession; restoreWorkspaceSession: (input: unknown) => boolean; setDraft: (draft: string) => void; tickTimer: (now?: number) => void; @@ -86,20 +68,6 @@ interface SparringStoreState { // AuthProvider calls resetLocalData before hydrating a different user. let hydrationEpoch = 0; -const SavedRowsSchema = z.array(z.object({ - id: z.string(), - motion: z.string(), - user_side: z.enum(['pro', 'con']), - format: z.enum(['standard', 'ld', 'pf']).catch('standard'), - difficulty: z.enum(['easy', 'medium', 'hard']).catch('medium'), - turns: z.unknown(), - final_evaluation: z.unknown(), - status: z.string(), - debate_id: z.string().nullable().optional(), - created_at: z.string(), - updated_at: z.string(), -})); - const PersistedSparringSessionSchema = z.object({ id: z.string(), motion: z.string(), @@ -129,7 +97,7 @@ const PersistedSparringStateSchema = z.object({ }); type PersistedSparringState = Pick; -const MAX_SAVED_SESSIONS = 50; +const MAX_SAVED_SESSIONS = MAX_SAVED_SPARRING_SESSIONS; const PERSIST_DELAY_MS = 250; let pendingStorageWrite: { name: string; value: string } | null = null; let storageWriteTimer: ReturnType | null = null; @@ -209,45 +177,6 @@ function createId(): string { return `${segment()}${segment()}-${segment()}-4${segment().slice(1)}-a${segment().slice(1)}-${segment()}${segment()}${segment()}`; } -function currentTurn(session: SparringSession): SparringTurn | undefined { - return session.turns[session.currentTurnIndex]; -} - -function timerForTurn(turn: SparringTurn | undefined, now: number) { - const duration = turn?.timeLimitSeconds ?? 0; - const shouldRun = turn?.speaker === 'user'; - return { - timerRemainingSeconds: duration, - timerRunning: shouldRun, - timerDeadline: shouldRun ? now + duration * 1000 : null, - }; -} - -function advanceSession(session: SparringSession, now: number): SparringSession { - const nextIndex = session.currentTurnIndex + 1; - const nextTurn = session.turns[nextIndex]; - if (!nextTurn) { - return { - ...session, - currentTurnIndex: session.turns.length, - phase: 'judging', - draft: '', - timerRemainingSeconds: 0, - timerRunning: false, - timerDeadline: null, - updatedAt: now, - }; - } - - return { - ...session, - currentTurnIndex: nextIndex, - draft: '', - ...timerForTurn(nextTurn, now), - updatedAt: now, - }; -} - function captureRoundCompletion(previous: SparringSession, next: SparringSession): void { if (previous.phase !== 'round' || next.phase !== 'judging') return; captureProductEvent('sparring_round_completed', { @@ -256,47 +185,12 @@ function captureRoundCompletion(previous: SparringSession, next: SparringSession }); } -function mergeSessions(local: SparringSession[], remote: SparringSession[]): SparringSession[] { - const byId = new Map(); - for (const session of [...local, ...remote]) { - const current = byId.get(session.id); - if (!current || current.updatedAt < session.updatedAt) byId.set(session.id, session); - } - return [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SAVED_SESSIONS); -} - -function parseSavedRows(value: unknown): SparringSession[] { - const parsedRows = SavedRowsSchema.safeParse(value); - if (!parsedRows.success) return []; - - return parsedRows.data.flatMap((row): SparringSession[] => { - const speeches = z.array(SparringSpeechSchema).max(12).safeParse(row.turns); - const scorecard = SparringScorecardSchema.safeParse(row.final_evaluation); - if (!speeches.success || !scorecard.success || row.status !== 'complete') return []; - - const turns = buildSparringTurns(row.format, row.user_side); - const createdAt = new Date(row.created_at).getTime(); - const updatedAt = new Date(row.updated_at).getTime(); - return [{ - id: row.id, - motion: row.motion, - userSide: row.user_side, - format: row.format, - difficulty: row.difficulty, - turns, - speeches: speeches.data, - currentTurnIndex: turns.length, - phase: 'complete', - draft: '', - timerRemainingSeconds: 0, - timerRunning: false, - timerDeadline: null, - scorecard: scorecard.data, - debateId: row.debate_id ?? null, - createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), - updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(), - }]; - }); +/** Applies a move to the current round; an unchanged round keeps the state, so nothing re-renders. */ +function updateCurrent(state: SparringStoreState, move: (session: SparringSession) => SparringSession): SparringStoreState | Pick { + const session = state.currentSession; + if (!session) return state; + const next = move(session); + return next === session ? state : { currentSession: next }; } export const useSparringStore = create()( @@ -319,25 +213,7 @@ export const useSparringStore = create()( })), startSession: (input) => { - const now = Date.now(); - const turns = buildSparringTurns(input.format, input.userSide); - const session: SparringSession = { - id: createId(), - motion: input.motion.trim(), - userSide: input.userSide, - format: input.format, - difficulty: input.difficulty, - turns, - speeches: [], - currentTurnIndex: 0, - phase: 'round', - draft: '', - ...timerForTurn(turns[0], now), - debateId: input.debateId ?? null, - ...(input.voice ? { voice: true } : {}), - createdAt: now, - updatedAt: now, - }; + const session = startSparringSession(input, createId(), Date.now()); set({ currentSession: session }); return session; }, @@ -353,66 +229,15 @@ export const useSparringStore = create()( ? { currentSession: { ...state.currentSession, draft, updatedAt: Date.now() } } : state), - tickTimer: (now = Date.now()) => set((state) => { - const session = state.currentSession; - if (!session?.timerRunning || session.timerDeadline === null) return state; - const remaining = Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)); - if (remaining === session.timerRemainingSeconds) return state; - return { - currentSession: { - ...session, - timerRemainingSeconds: remaining, - timerRunning: remaining > 0, - timerDeadline: remaining > 0 ? session.timerDeadline : null, - }, - }; - }), - - pauseTimer: (now = Date.now()) => set((state) => { - const session = state.currentSession; - if (!session?.timerRunning || session.timerDeadline === null) return state; - const remaining = Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)); - return { currentSession: { ...session, timerRemainingSeconds: remaining, timerRunning: false, timerDeadline: null } }; - }), - - resumeTimer: (now = Date.now()) => set((state) => { - const session = state.currentSession; - const turn = session ? currentTurn(session) : undefined; - if (!session || session.phase !== 'round' || turn?.speaker !== 'user' || session.timerRunning || session.timerRemainingSeconds <= 0) return state; - return { currentSession: { ...session, timerRunning: true, timerDeadline: now + session.timerRemainingSeconds * 1000 } }; - }), - - resetTurnTimer: (now = Date.now()) => set((state) => { - const session = state.currentSession; - const turn = session ? currentTurn(session) : undefined; - if (!session || turn?.speaker !== 'user') return state; - return { currentSession: { ...session, ...timerForTurn(turn, now) } }; - }), + tickTimer: (now = Date.now()) => set((state) => updateCurrent(state, (session) => tickSparringTimer(session, now))), + pauseTimer: (now = Date.now()) => set((state) => updateCurrent(state, (session) => pauseSparringTimer(session, now))), + resumeTimer: (now = Date.now()) => set((state) => updateCurrent(state, (session) => resumeSparringTimer(session, now))), + resetTurnTimer: (now = Date.now()) => set((state) => updateCurrent(state, (session) => resetSparringTurnTimer(session, now))), submitUserSpeech: (content, now = Date.now(), delivery = null) => { const session = get().currentSession; - const turn = session ? currentTurn(session) : undefined; - const cleanContent = content.trim(); - const deadlinePassed = session?.timerDeadline !== null - && session?.timerDeadline !== undefined - && session.timerDeadline <= now; - if (!session || session.phase !== 'round' || turn?.speaker !== 'user' || !cleanContent || cleanContent.length > 6_000 || session.timerRemainingSeconds <= 0 || deadlinePassed) return null; - const remaining = session.timerRunning && session.timerDeadline !== null - ? Math.max(0, Math.ceil((session.timerDeadline - now) / 1000)) - : session.timerRemainingSeconds; - const speech: SparringSpeech = { - id: createId(), - turnId: turn.id, - speaker: 'user', - side: turn.side, - role: turn.label, - content: cleanContent, - createdAt: now, - timeLimitSeconds: turn.timeLimitSeconds, - timeUsedSeconds: Math.max(0, turn.timeLimitSeconds - remaining), - ...(delivery ? { delivery } : {}), - }; - const next = advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); + const next = session ? submitSparringSpeech(session, content, { now, createId, delivery }) : null; + if (!session || !next) return null; set({ currentSession: next }); captureRoundCompletion(session, next); return next; @@ -420,10 +245,8 @@ export const useSparringStore = create()( appendAiSpeech: (speech, now = Date.now()) => { const session = get().currentSession; - const turn = session ? currentTurn(session) : undefined; - const alreadyRecorded = session?.speeches.some((item) => item.id === speech.id || item.turnId === speech.turnId); - if (!session || session.phase !== 'round' || turn?.speaker !== 'ai' || speech.turnId !== turn.id || alreadyRecorded) return null; - const next = advanceSession({ ...session, speeches: [...session.speeches, speech] }, now); + const next = session ? appendSparringAiSpeech(session, speech, now) : null; + if (!session || !next) return null; set({ currentSession: next }); captureRoundCompletion(session, next); return next; @@ -431,16 +254,8 @@ export const useSparringStore = create()( completeSession: (scorecard, now = Date.now()) => { const session = get().currentSession; - if (!session || session.phase !== 'judging') return null; - const completed: SparringSession = { - ...session, - phase: 'complete', - scorecard, - timerRemainingSeconds: 0, - timerRunning: false, - timerDeadline: null, - updatedAt: now, - }; + const completed = session ? completeSparringSession(session, scorecard, now) : null; + if (!completed) return null; set((state) => ({ currentSession: completed, savedSessions: mergeSessions(state.savedSessions, [completed]), @@ -486,7 +301,7 @@ export const useSparringStore = create()( } const { data, error } = await supabase .from('sparring_sessions') - .select('id,motion,user_side,format,difficulty,turns,final_evaluation,status,debate_id,created_at,updated_at') + .select(SPARRING_SESSION_COLUMNS) .order('updated_at', { ascending: false }); if (epoch !== hydrationEpoch) return; if (error) { diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index 24b249509..c58dc0267 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -22,17 +22,13 @@ import { OpponentVoiceBar } from '@/components/features/voice/OpponentVoiceBar'; import { useVoiceAvailability } from '@/lib/voice/availability'; import type { SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; +import { estimateAiTimeUsedSeconds } from '@/lib/sparring/session'; function readableError(error: unknown, fallback: string) { if (error instanceof Error && error.message.trim()) return error.message; return fallback; } -function estimateAiTimeUsedSeconds(content: string, limit: number) { - const words = content.trim() ? content.trim().split(/\s+/).length : 0; - return Math.min(limit, Math.max(1, Math.round((words / 140) * 60))); -} - export default function SparringPage() { const workspace = useWorkspaceToolScope('sparring'); const debateGate = useDebateMotionGate({ disabled: Boolean(workspace.context) }); diff --git a/src/test/sparring/session.test.ts b/src/test/sparring/session.test.ts new file mode 100644 index 000000000..93992dde6 --- /dev/null +++ b/src/test/sparring/session.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + appendSparringAiSpeech, + completeSparringSession, + pauseSparringTimer, + resumeSparringTimer, + startSparringSession, + submitSparringSpeech, + tickSparringTimer, + type SparringSession, +} from '@/lib/sparring/session'; +import type { SparringScorecard, SparringSpeech } from '@/lib/ai/schemas/sparring'; + +const START = 1_000_000; +let ids = 0; +const createId = () => `speech-${++ids}`; + +function aiSpeech(session: SparringSession, turnIndex = session.currentTurnIndex): SparringSpeech { + const turn = session.turns[turnIndex]; + return { + id: `ai-${session.id}-${turn.id}`, + turnId: turn.id, + speaker: 'ai', + side: turn.side, + role: turn.label, + content: 'The opponent answers.', + createdAt: START, + timeLimitSeconds: turn.timeLimitSeconds, + timeUsedSeconds: 10, + }; +} + +const scorecard: SparringScorecard = { + userScore: 60, + aiScore: 50, + categories: [ + { name: 'Logic', userScore: 15, aiScore: 12, feedback: 'f' }, + { name: 'Evidence', userScore: 15, aiScore: 13, feedback: 'f' }, + { name: 'Delivery', userScore: 15, aiScore: 12, feedback: 'f' }, + { name: 'Strategy', userScore: 15, aiScore: 13, feedback: 'f' }, + ], + overallFeedback: 'o', + keyMoments: [], + improvementAreas: [], + winner: 'user', + verdict: 'v', +}; + +describe('sparring round moves', () => { + it('runs a Lincoln-Douglas round as Con through the opponent and student turns to judging', () => { + let session = startSparringSession({ motion: ' A motion ', userSide: 'con', format: 'ld', difficulty: 'medium' }, 'round-1', START); + expect(session.motion).toBe('A motion'); + // The opponent opens, so no clock runs and the student cannot speak. + expect(session.timerRunning).toBe(false); + expect(submitSparringSpeech(session, 'Too early', { now: START, createId })).toBeNull(); + + for (let turn = 0; turn < session.turns.length; turn += 1) { + const speaker = session.turns[session.currentTurnIndex].speaker; + const next = speaker === 'ai' + ? appendSparringAiSpeech(session, aiSpeech(session), START + turn * 1000) + : submitSparringSpeech(session, `Speech ${turn}`, { now: START + turn * 1000, createId }); + expect(next).not.toBeNull(); + session = next!; + if (session.phase === 'round' && session.turns[session.currentTurnIndex].speaker === 'user') { + // The student's clock starts the moment their turn begins. + expect(session.timerRunning).toBe(true); + expect(session.timerDeadline).toBe(START + turn * 1000 + session.turns[session.currentTurnIndex].timeLimitSeconds * 1000); + } + } + + expect(session.phase).toBe('judging'); + expect(session.speeches.map((speech) => speech.speaker)).toEqual(['ai', 'user', 'ai', 'user', 'ai']); + expect(session.timerRunning).toBe(false); + expect(appendSparringAiSpeech(session, aiSpeech(session, 4), START)).toBeNull(); + const judged = completeSparringSession(session, scorecard, START + 9000); + expect(judged?.phase).toBe('complete'); + expect(completeSparringSession(judged!, scorecard, START)).toBeNull(); + }); + + it('records an opponent speech only for its own turn, and only once', () => { + const session = startSparringSession({ motion: 'M', userSide: 'con', format: 'pf', difficulty: 'easy' }, 'round-2', START); + expect(appendSparringAiSpeech(session, aiSpeech(session, 2), START)).toBeNull(); + const next = appendSparringAiSpeech(session, aiSpeech(session), START)!; + const late = { ...aiSpeech(session), id: 'another-id' }; + const back = { ...next, currentTurnIndex: 0 }; + expect(appendSparringAiSpeech(back, late, START)).toBeNull(); + }); + + it('refuses a speech after the deadline, even before a tick has noticed', () => { + const session = startSparringSession({ motion: 'M', userSide: 'pro', format: 'pf', difficulty: 'hard' }, 'round-3', START); + const deadline = session.timerDeadline!; + expect(submitSparringSpeech(session, 'Late', { now: deadline, createId })).toBeNull(); + expect(submitSparringSpeech(session, ' ', { now: START, createId })).toBeNull(); + expect(submitSparringSpeech(session, 'x'.repeat(6_001), { now: START, createId })).toBeNull(); + const accepted = submitSparringSpeech(session, 'On time', { now: deadline - 30_500, createId })!; + expect(accepted.speeches[0].timeUsedSeconds).toBe(240 - 31); + }); + + it('keeps the time left across a pause and counts from the deadline after resuming', () => { + const session = startSparringSession({ motion: 'M', userSide: 'pro', format: 'pf', difficulty: 'medium' }, 'round-4', START); + // Within the same second the round is unchanged, so nothing re-renders. + expect(tickSparringTimer(session, START + 400)).toBe(session); + const paused = pauseSparringTimer(session, START + 60_200); + expect(paused).toMatchObject({ timerRunning: false, timerDeadline: null, timerRemainingSeconds: 180 }); + expect(pauseSparringTimer(paused, START + 90_000)).toBe(paused); + const resumed = resumeSparringTimer(paused, START + 600_000); + expect(resumed.timerDeadline).toBe(START + 600_000 + 180_000); + // A tick long after the deadline (the app was in the background) stops at zero. + expect(tickSparringTimer(resumed, START + 2_000_000)).toMatchObject({ timerRemainingSeconds: 0, timerRunning: false, timerDeadline: null }); + }); +}); From 5a714c7ca3f3d7d5294a1a2d9e548c8268299845 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:45:22 +0000 Subject: [PATCH 2/8] Add typed Sparring to the app Sparring mirrors the website's typed round: a setup form, the side asked when the round starts, the opponent's turns and the student's typed speeches on each speech's clock, judging, and the scorecard as bars (mocks 05 and 06). The server saves the judged round; the app files it in its debate, reopens saved rounds from Practice and the Debate tab, and deletes them after confirming. The voice switch shows disabled until the development build. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 5 +- .../src/app/(tabs)/(practice)/practice.tsx | 103 ++- apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/sparring/[id].tsx | 83 +++ apps/mobile/src/app/sparring/index.tsx | 693 ++++++++++++++++++ apps/mobile/src/components/document.tsx | 21 +- apps/mobile/src/components/practice-tools.ts | 2 +- apps/mobile/src/components/round-controls.tsx | 18 +- apps/mobile/src/components/sparring-views.tsx | 381 ++++++++++ apps/mobile/src/components/work-row.tsx | 4 +- apps/mobile/src/lib/debates.ts | 3 +- apps/mobile/src/lib/keep-if-same.ts | 4 + apps/mobile/src/lib/sparring.ts | 139 ++++ apps/mobile/src/lib/timer-signals.ts | 5 + apps/mobile/src/lib/timer-signals.web.ts | 6 + src/locales/en.json | 13 + src/locales/es.json | 13 + src/locales/hu.json | 13 + 18 files changed, 1482 insertions(+), 26 deletions(-) create mode 100644 apps/mobile/src/app/sparring/[id].tsx create mode 100644 apps/mobile/src/app/sparring/index.tsx create mode 100644 apps/mobile/src/components/sparring-views.tsx create mode 100644 apps/mobile/src/lib/keep-if-same.ts create mode 100644 apps/mobile/src/lib/sparring.ts diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index d96f3b6bc..722e06000 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,5 +1,5 @@ import { LinearGradient } from 'expo-linear-gradient'; -import { useFocusEffect, useRouter, type Href } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; @@ -171,7 +171,6 @@ function DebateDetails({ debateId }: { debateId: string }) { }, [load]), ); - const open = (href: Href) => router.navigate(href); const results = counts?.results[view]; const items = recent?.[view] ?? null; @@ -194,7 +193,7 @@ function DebateDetails({ debateId }: { debateId: string }) { - open('/practice')} /> + router.push({ pathname: '/sparring', params: { from: 'debate' } })} /> {t('mobile.tabs.prepare')} diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 7d5f944f1..5f7c65356 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -1,20 +1,71 @@ -import { useRouter } from 'expo-router'; +import { useFocusEffect, useRouter, type Href } from 'expo-router'; +import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; import { Card, IconTile } from '@/components/card'; import { Icon } from '@/components/icon'; -import { PRACTICE_TOOLS } from '@/components/practice-tools'; +import { PRACTICE_TOOLS, type PracticeTool } from '@/components/practice-tools'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; import { Text } from '@/components/text'; +import { WorkRow } from '@/components/work-row'; +import { useAuth } from '@/lib/auth'; +import { useDebates } from '@/lib/debate-context'; +import { keepIfSame } from '@/lib/keep-if-same'; +import { fetchSparringRounds, type SparringRoundSummary } from '@/lib/sparring'; import { useTheme } from '@/theme/use-theme'; -/** The Practice tools. Attack, Ask, Crossfire and Timer open full screen; the others say they are on their way. */ +const ROUNDS_LIMIT = 5; + +const TOOL_ROUTES: Record = { + attack: { pathname: '/attack', params: { from: 'practice' } }, + ask: '/ask', + crossfire: { pathname: '/crossfire', params: { from: 'practice' } }, + sparring: { pathname: '/sparring', params: { from: 'practice' } }, + timer: '/timer', +}; + +/** + * The Practice tools, each opening full screen, and the judged Sparring + * rounds to reopen. Rounds come from the Current Debate, or from outside every + * debate when there is none, as the website's history picks them. + */ export default function PracticeScreen() { const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const { current } = useDebates(); + const debateId = current?.id ?? null; + const [rounds, setRounds] = useState<{ debateId: string | null; items: SparringRoundSummary[] } | null>(null); + const [failed, setFailed] = useState(false); + + // Reload on focus, so a round just judged or deleted shows up. + useFocusEffect( + useCallback(() => { + if (!userId) return; + let active = true; + fetchSparringRounds(userId, debateId, ROUNDS_LIMIT) + .then((items) => { + if (!active) return; + setRounds((previous) => keepIfSame(previous, { debateId, items })); + setFailed(false); + }) + .catch((cause: unknown) => { + console.warn('Could not load sparring rounds:', cause instanceof Error ? cause.message : cause); + if (active) setFailed(true); + }); + return () => { + active = false; + }; + }, [userId, debateId]), + ); + + // Rounds read for another debate are not shown while this one's load. + const items = rounds?.debateId === debateId ? rounds.items : null; + return ( @@ -23,16 +74,7 @@ export default function PracticeScreen() { - tool.key === 'ask' - ? router.push('/ask') - : tool.key === 'attack' - ? router.push({ pathname: '/attack', params: { from: 'practice' } }) - : tool.key === 'timer' - ? router.push('/timer') - : tool.key === 'crossfire' - ? router.push({ pathname: '/crossfire', params: { from: 'practice' } }) - : router.push({ pathname: '/soon/[tool]', params: { tool: tool.key } })} + onPress={() => router.push(TOOL_ROUTES[tool.key])} testID={`practice-tool-${tool.key}`} style={({ pressed }) => [styles.row, index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> @@ -48,6 +90,39 @@ export default function PracticeScreen() { ))} + + {/* The list appears once it has loaded, so the tools never wait behind a spinner. */} + {items === null && !failed ? null : ( + <> + + {t('historyPage.sparringTitle')} + + {items === null ? ( + + + {t('mobile.sparring.roundsFailed')} + + + ) : items.length === 0 ? ( + + + {t('historyPage.emptySparringDesc')} + + + ) : ( + + {items.map((round, index) => ( + router.push({ pathname: '/sparring/[id]', params: { id: round.id, from: 'practice' } })} + /> + ))} + + )} + + )} ); } @@ -59,4 +134,6 @@ const styles = StyleSheet.create({ rowTitle: { fontSize: 16 }, rowMeta: { fontSize: 14 }, pressed: { opacity: 0.7 }, + section: { fontSize: 20, lineHeight: 26, marginTop: 6 }, + body: { fontSize: 15.5, lineHeight: 22 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index c56617187..859311105 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -89,6 +89,8 @@ function Root() { {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + + diff --git a/apps/mobile/src/app/sparring/[id].tsx b/apps/mobile/src/app/sparring/[id].tsx new file mode 100644 index 000000000..6e0d4f1c3 --- /dev/null +++ b/apps/mobile/src/app/sparring/[id].tsx @@ -0,0 +1,83 @@ +import type { SparringScorecard } from '@debatelab/core/ai/schemas/sparring'; +import type { SparringSession } from '@debatelab/core/sparring/session'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { SparringScorecardScreen } from '@/components/sparring-views'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { fetchSparringRound, sparringDraft } from '@/lib/sparring'; + +type Judged = SparringSession & { scorecard: SparringScorecard }; +type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' } | { status: 'ready'; round: Judged }; + +function judged(session: SparringSession | null | undefined, id: string): Judged | null { + return session?.id === id && session.phase === 'complete' && session.scorecard ? { ...session, scorecard: session.scorecard } : null; +} + +/** + * A saved round reopened from Practice or the Debate tab, as the website's + * history loads a saved session back into Sparring: the scorecard, Replay + * for the transcript, share and delete. + */ +export default function SavedSparringScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { id, from } = useLocalSearchParams<{ id: string; from?: string }>(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [state, setState] = useState(() => { + const kept = judged(sparringDraft.get(), id); + return kept ? { status: 'ready', round: kept } : { status: 'loading' }; + }); + + useEffect(() => { + if (judged(sparringDraft.get(), id) || !userId) return; + let active = true; + fetchSparringRound(userId, id) + .then((round) => { + if (!active) return; + const ready = judged(round, id); + setState(ready ? { status: 'ready', round: ready } : { status: 'missing' }); + }) + .catch((cause: unknown) => { + console.warn('Could not load the round:', cause instanceof Error ? cause.message : cause); + if (active) setState({ status: 'failed' }); + }); + return () => { + active = false; + }; + }, [id, userId]); + + if (state.status !== 'ready') { + return ( + }> + + + ); + } + + const forget = () => { + // The finished round on the Sparring screen is this one; another round, finished or not, stays. + if (sparringDraft.get()?.id === id) sparringDraft.set(null); + }; + + return ( + { + forget(); + router.replace({ pathname: '/sparring', params: { from } }); + }} + onDeleted={() => { + forget(); + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx new file mode 100644 index 000000000..8c4df77da --- /dev/null +++ b/apps/mobile/src/app/sparring/index.tsx @@ -0,0 +1,693 @@ +import type { SparringDifficulty, SparringFormat, SparringSide, SparringSpeech } from '@debatelab/core/ai/schemas/sparring'; +import { buildSparringTurns, formatRoundTime, getSparringFormat, SPARRING_FORMATS } from '@debatelab/core/sparring/formats'; +import { + appendSparringAiSpeech, + completeSparringSession, + currentSparringTurn, + estimateAiTimeUsedSeconds, + MAX_SPARRING_SPEECH_CHARS, + pauseSparringTimer, + resetSparringTurnTimer, + resumeSparringTimer, + startSparringSession, + submitSparringSpeech, + tickSparringTimer, + type SparringSession, +} from '@debatelab/core/sparring/session'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, AppState, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ActionMenu } from '@/components/action-menu'; +import { MainActionButton, TextButton } from '@/components/buttons'; +import { DocumentScreen } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; +import { Icon } from '@/components/icon'; +import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; +import { SegmentedControl } from '@/components/segmented-control'; +import { RoundNotesSheet, SparringScorecardScreen } from '@/components/sparring-views'; +import { Text } from '@/components/text'; +import { TimerRing } from '@/components/timer-ring'; +import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { AIError, isAbortError } from '@/lib/ai-request'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { useDebates } from '@/lib/debate-context'; +import { fetchFeatureConfig } from '@/lib/feature-config'; +import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; +import { chooseMotionTarget } from '@/lib/motion-gate'; +import { useSettings } from '@/lib/settings'; +import { attachSparring, judgeSparring, newSparringId, SparringResponseError, sparringDraft, sparringNotes, sparringTurn } from '@/lib/sparring'; +import { formatClock } from '@/lib/timer'; +import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +const TICK_MS = 250; +const MIN_MOTION_CHARS = 8; +const PLAN_ERRORS = new Set(['trialExpired', 'limitReached', 'modelRestricted']); +/** The turn kinds the website gives school-format guidance for. */ +const ROLE_GUIDANCE = new Set(['introductor', 'r1', 'r2', 'conclusor']); + +/** A failed opponent turn or judge request. Messages are translation keys, so they read in the current language. */ +type Failure = { message: string; plan: boolean }; +type RequestState = { status: 'idle' } | { status: 'pending' } | { status: 'failed'; failure: Failure }; + +function needsRequest(session: SparringSession | null): boolean { + if (!session) return false; + return session.phase === 'judging' || (session.phase === 'round' && currentSparringTurn(session)?.speaker === 'ai'); +} + +function classify(cause: unknown, judge: boolean): Failure { + if (cause instanceof SparringResponseError) { + return { message: judge ? 'sparring.round.judgeRequestFailed' : 'sparring.round.aiRequestFailed', plan: false }; + } + if (cause instanceof AIError) return { message: `mobile.results.errors.${cause.kind}`, plan: PLAN_ERRORS.has(cause.kind) }; + console.warn('Sparring request failed:', cause instanceof Error ? cause.message : cause); + return { message: 'mobile.results.errors.failed', plan: false }; +} + +/** + * Sparring in its typed mode, as the website's SparringPage: set up a motion, + * pick a side when the round starts, give your speeches in format order + * against the AI opponent on each speech's clock, and read the judge's + * scorecard. The live round is full screen like mock 05 and stays for the + * session if the student leaves it; a request cut off by leaving is offered + * again. Voice rounds need the development build. + */ +export default function SparringScreen() { + const { t } = useTranslation(); + const router = useRouter(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { session: auth } = useAuth(); + const userId = auth?.user.id ?? null; + const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); + const { outputLanguage } = useSettings(); + const { colors } = useTheme(); + + const [session, setSession] = useState(() => sparringDraft.get()); + // Moves read the latest round from here, so a reply that lands after a keystroke keeps the keystroke. + const sessionRef = useRef(session); + const [request, setRequest] = useState(() => + needsRequest(sparringDraft.get()) ? { status: 'failed', failure: { message: 'mobile.sparring.interrupted', plan: false } } : { status: 'idle' }); + const [motion, setMotion] = useState(''); + const [format, setFormat] = useState('standard'); + const [difficulty, setDifficulty] = useState('medium'); + const [formError, setFormError] = useState(''); + const [sideMenu, setSideMenu] = useState(false); + const [notice, setNotice] = useState(''); + const [submissionError, setSubmissionError] = useState(''); + const requestRef = useRef(null); + const live = useRef(true); + const owner = useRef(userId); + + useEffect(() => { + owner.current = userId; + }, [userId]); + + /** The one way the round changes: kept for the session as it changes, as the website keeps it in the tab. */ + const commit = useCallback((next: SparringSession | null) => { + sessionRef.current = next; + sparringDraft.set(next); + setSession(next); + }, []); + + // Leaving cancels what is on its way; the round itself stays for the session. + useEffect(() => { + live.current = true; + return () => { + live.current = false; + requestRef.current?.abort(); + }; + }, []); + + // The motion follows the Current Debate unless the student typed their own. + useEffect(() => { + if (session || oneOff || !current) return; + if (shouldPrefillMotion(motion, current.motion, debates.map((debate) => debate.motion))) setMotion(current.motion); + }, [current, debates, motion, oneOff, session]); + + const runJudge = useCallback(async (snapshot: SparringSession, forUser: string) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + setRequest({ status: 'pending' }); + try { + const config = await fetchFeatureConfig(forUser, 'sparring'); + if (controller.signal.aborted) return; + const scorecard = await judgeSparring( + { action: 'score', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage }, + { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, + ); + if (controller.signal.aborted || owner.current !== forUser) return; + const latest = sessionRef.current; + const completed = latest?.id === snapshot.id ? completeSparringSession(latest, scorecard, Date.now()) : null; + setRequest({ status: 'idle' }); + if (!completed) return; + commit(completed); + // The server saved the judged round before it answered; file it in its debate. + if (completed.debateId) { + attachSparring(completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { + console.warn('Could not file the round in its debate:', cause instanceof Error ? cause.message : cause); + if (live.current && owner.current === forUser) setNotice(t('debate.recordFailed')); + }); + } + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + setRequest({ status: 'failed', failure: classify(cause, true) }); + } + }, [commit, outputLanguage, refresh, t]); + + const runTurn = useCallback(async (snapshot: SparringSession, forUser: string) => { + const turn = currentSparringTurn(snapshot); + if (snapshot.phase !== 'round' || turn?.speaker !== 'ai') return; + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + setRequest({ status: 'pending' }); + try { + const config = await fetchFeatureConfig(forUser, 'sparring'); + if (controller.signal.aborted) return; + const { speaker: _speaker, ...currentTurn } = turn; + const generated = await sparringTurn( + { action: 'respond', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage, currentTurn }, + { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, + ); + if (controller.signal.aborted || owner.current !== forUser) return; + const speech: SparringSpeech = { + id: `ai-${snapshot.id}-${turn.id}`, + turnId: turn.id, + speaker: 'ai', + side: turn.side, + role: turn.label, + content: generated.content, + createdAt: Date.now(), + timeLimitSeconds: turn.timeLimitSeconds, + timeUsedSeconds: estimateAiTimeUsedSeconds(generated.content, turn.timeLimitSeconds), + integrityToken: generated.integrityToken, + }; + const latest = sessionRef.current; + const next = latest?.id === snapshot.id ? appendSparringAiSpeech(latest, speech, Date.now()) : null; + setRequest({ status: 'idle' }); + if (!next) return; + commit(next); + if (next.phase === 'judging') void runJudge(next, forUser); + else if (currentSparringTurn(next)?.speaker === 'user') tapTurnStart(); + } catch (cause) { + if (controller.signal.aborted || isAbortError(cause)) return; + if (cause instanceof AIError && cause.kind === 'debateMissing') void refresh(); + setRequest({ status: 'failed', failure: classify(cause, false) }); + } + }, [commit, outputLanguage, refresh, runJudge]); + + // The clock counts down from its deadline; the tick only redraws it, and coming back from the background redraws at once. + const timerRunning = session?.timerRunning ?? false; + useEffect(() => { + if (!timerRunning) return; + const tick = () => { + const latest = sessionRef.current; + if (!latest) return; + const next = tickSparringTimer(latest, Date.now()); + if (next !== latest) commit(next); + }; + tick(); + const timer = setInterval(tick, TICK_MS); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') tick(); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [commit, timerRunning]); + + const begin = (side: SparringSide, debateId: string | null, forUser: string) => { + const started = startSparringSession({ motion, userSide: side, format, difficulty, debateId }, newSparringId(), Date.now()); + setNotice(''); + setSubmissionError(''); + commit(started); + if (currentSparringTurn(started)?.speaker === 'ai') void runTurn(started, forUser); + else tapTurnStart(); + }; + + const start = () => { + if (motion.trim().length < MIN_MOTION_CHARS) { + setFormError(t('sparring.setup.motionTooShort')); + return; + } + setFormError(''); + setSideMenu(true); + }; + + // The side comes first, then where the round belongs, settled before it starts as the website's motion gate does. + const chooseSide = async (side: SparringSide) => { + if (!userId) return; + const forUser = userId; + const text = motion.trim(); + const decision = decideMotionTarget(text, { debates, current, oneOff }); + if (decision.kind !== 'ask') { + begin(side, decision.kind === 'attach' ? decision.debateId : null, forUser); + return; + } + const choice = await chooseMotionTarget(decision.matches, { + title: current ? t('debate.mismatchTitle') : t('debate.offerTitle'), + message: `“${text}”\n\n${current ? t('debate.mismatchDesc', { motion: current.motion }) : t('debate.offerDesc')}`, + switchLabel: t('debate.switchToExisting'), + createLabel: current ? t('debate.createFromMotion') : t('debate.startDebate'), + onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), + cancel: t('common.cancel'), + }); + if (!choice || !live.current || owner.current !== forUser) return; + let debateId: string | null = null; + try { + if (choice.kind === 'switch') { + await switchTo(choice.debateId); + debateId = choice.debateId; + } else if (choice.kind === 'create') { + debateId = (await create({ motion: text, settings: {} })).id; + } + } catch (cause) { + console.warn('Could not save the debate:', cause instanceof Error ? cause.message : cause); + if (live.current) setFormError(t('debate.saveFailed')); + return; + } + if (live.current && owner.current === forUser) begin(side, debateId, forUser); + }; + + const submit = () => { + const latest = sessionRef.current; + if (!latest || !userId) return; + Keyboard.dismiss(); + const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId }); + if (!next) { + setSubmissionError(t('sparring.round.speechRejected')); + return; + } + setSubmissionError(''); + commit(next); + if (next.phase === 'judging') void runJudge(next, userId); + else void runTurn(next, userId); + }; + + const retry = () => { + const latest = sessionRef.current; + if (!latest || !userId) return; + if (latest.phase === 'judging') void runJudge(latest, userId); + else void runTurn(latest, userId); + }; + + const restart = () => { + requestRef.current?.abort(); + setRequest({ status: 'idle' }); + setNotice(''); + setSubmissionError(''); + commit(null); + }; + + const move = (change: (round: SparringSession, now: number) => SparringSession) => { + const latest = sessionRef.current; + if (!latest) return; + const next = change(latest, Date.now()); + if (next !== latest) commit(next); + }; + + if (!session) { + const turns = buildSparringTurns(format, 'pro'); + return ( + } + actions={}> + + + + + + {t('sparring.setup.choosePressure')} + + + value={difficulty} + onChange={setDifficulty} + options={(['easy', 'medium', 'hard'] as const).map((level) => ({ value: level, label: t(`sparring.difficulty.${level}`) }))} + /> + + + + first + label={t('sparring.setup.roundFormat')} + value={format} + onChange={setFormat} + options={SPARRING_FORMATS.map((item) => ({ value: item.id, label: t(`sparring.format.${item.id}`) }))} + testID="sparring-format" + /> + + + {turns.map((turn, index) => ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + {String(index + 1).padStart(2, '0')} + + + {t(`sparring.turns.${turn.id}`)} + + + {formatRoundTime(turn.timeLimitSeconds)} + + + ))} + + + {/* Voice rounds need speech recognition and the opponent's voice, which come with the development build. */} + {}} + disabled + /> + + {formError ? ( + + {formError} + + ) : null} + setSideMenu(false)} + onSelect={(key) => void chooseSide(key === 'con' ? 'con' : 'pro')} + testID="sparring-side-menu" + /> + + ); + } + + if (session.phase === 'complete' && session.scorecard) { + return ( + { + if (sessionRef.current?.id === session.id) restart(); + }} + /> + ); + } + + return ( + router.back()} + onDraft={(draft) => move((round, now) => ({ ...round, draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }))} + onPause={() => move(pauseSparringTimer)} + onResume={() => move(resumeSparringTimer)} + onReset={() => move(resetSparringTurnTimer)} + onSubmit={submit} + onRetry={retry} + /> + ); +} + +/** The live round from mock 05: the speech's ring timer, the opponent's speech as it arrives, your typed speech, and the controls. */ +function LiveRound({ + session, + request, + notice, + submissionError, + onClose, + onDraft, + onPause, + onResume, + onReset, + onSubmit, + onRetry, +}: { + session: SparringSession; + request: RequestState; + notice: string; + submissionError: string; + onClose: () => void; + onDraft: (draft: string) => void; + onPause: () => void; + onResume: () => void; + onReset: () => void; + onSubmit: () => void; + onRetry: () => void; +}) { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const { colors } = useTheme(); + const [notesOpen, setNotesOpen] = useState(false); + const [notes, setNotes] = useState(() => sparringNotes.get()[session.id] ?? ''); + const [keyboard, setKeyboard] = useState(false); + const [expanded, setExpanded] = useState(false); + useKeepAwakeWhile(true); + + useEffect(() => { + const show = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', () => setKeyboard(true)); + const hide = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKeyboard(false)); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + + const saveNotes = (value: string) => { + setNotes(value); + sparringNotes.set({ ...sparringNotes.get(), [session.id]: value }); + }; + + const judging = session.phase === 'judging'; + const turn = currentSparringTurn(session); + const userTurn = !judging && turn?.speaker === 'user'; + const failure = request.status === 'failed' ? request.failure : null; + const waiting = !failure && (judging || !userTurn); + const total = turn?.timeLimitSeconds ?? 0; + const remaining = judging ? 0 : session.timerRemainingSeconds; + const warning = userTurn && remaining <= 30; + const timeExpired = userTurn && (session.timerRemainingSeconds <= 0 || (session.timerDeadline !== null && session.timerDeadline <= Date.now())); + const lastAi = [...session.speeches].reverse().find((speech) => speech.speaker === 'ai'); + const turnLabel = turn ? t(`sparring.turns.${turn.id}`) : t('sparring.round.complete'); + const nextUserTurn = session.turns.slice(session.currentTurnIndex).find((item) => item.speaker === 'user'); + const ringSize = Math.round(Math.min(width - 96, height * (userTurn ? 0.27 : 0.34), 300)); + const status = judging ? t('sparring.round.complete') : userTurn ? t('sparring.round.yourSpeech') : t('sparring.round.opponentThinking'); + const guidance = turn && ROLE_GUIDANCE.has(turn.kind) ? t(`sparring.roleGuidance.${turn.kind}`) : t('sparring.round.yourSpeechDescription'); + const words = session.draft.trim() ? session.draft.trim().split(/\s+/).length : 0; + const canSubmit = userTurn && Boolean(session.draft.trim()) && !timeExpired; + const pressure = t(`sparring.difficulty.${session.difficulty}`); + + const main = failure + ? { icon: 'reset' as const, label: judging ? t('sparring.round.retryScoring') : t('sparring.round.retryOpponent'), onPress: onRetry, disabled: false } + : userTurn + ? { icon: 'send' as const, label: t('sparring.round.submitSpeech'), onPress: onSubmit, disabled: !canSubmit } + : { + icon: 'send' as const, + label: !judging && nextUserTurn ? t('mobile.sparring.yourNext', { turn: t(`sparring.turns.${nextUserTurn.id}`) }) : t('sparring.round.scoring'), + onPress: () => {}, + disabled: true, + }; + + return ( + + {/* The setup's document header is replaced by the live bar. */} + + } + right={ setNotesOpen(true)} testID="sparring-flow" />} + title={judging ? t('sparring.round.complete') : turnLabel} + subtitle={judging ? pressure : t('mobile.sparring.turnOf', { current: session.currentTurnIndex + 1, total: session.turns.length, pressure })} + /> + + {keyboard ? null : ( + + 0 ? remaining / total : 0} warning={warning}> + + {status} + + {judging ? ( + + ) : ( + + {formatClock(remaining)} + + )} + {judging ? null : ( + + {t('mobile.timer.ofTotal', { total: formatClock(total) })} + + )} + + + )} + + {notice ? ( + + {notice} + + ) : null} + + {failure ? ( + + + + {judging ? t('sparring.round.scoreFailed') : t('sparring.round.opponentFailed')} + + + {t(failure.message)} + + {failure.plan ? router.navigate('/you')} /> : null} + + ) : waiting ? ( + + + + {judging ? t('sparring.round.judgeWeighing') : t('sparring.round.opponentThinking')} + + + {judging ? t('sparring.round.judgeDescription') : t('sparring.round.opponentThinkingDescription')} + + + ) : null} + + {userTurn && lastAi ? ( + setExpanded((value) => !value)} + style={[styles.caption, { backgroundColor: colors.card, borderColor: colors.border }]} + testID="sparring-opponent-speech"> + + {`${t('sparring.opponent')} · ${t(`sparring.turns.${lastAi.turnId}`)}`} + + + {lastAi.content} + + + ) : null} + + {userTurn ? ( + + + {guidance} + + + + {t('sparring.round.wordCount', { count: words })} + + + ) : null} + {timeExpired ? ( + + {t('sparring.round.timeExpired')} + + ) : submissionError ? ( + + {submissionError} + + ) : null} + + + {keyboard ? ( + // With the keyboard up, the speech goes in from a slim bar above it, beside its clock. + + + {formatClock(remaining)} + + + + ) : ( + + + + + + )} + + setNotesOpen(false)} /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + flex: { flex: 1 }, + motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + group: { gap: 7 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + orderRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 15, minHeight: 44 }, + orderNumber: { fontSize: 13.5, fontVariant: ['tabular-nums'], width: 20 }, + orderLabel: { flex: 1, fontSize: 15.5 }, + orderTime: { fontSize: 14, fontVariant: ['tabular-nums'] }, + formError: { fontSize: 15, lineHeight: 21, marginLeft: 4 }, + content: { paddingHorizontal: 18, paddingTop: 14, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + ringWrap: { alignItems: 'center', marginTop: 8, marginBottom: 6 }, + status: { fontSize: 14, lineHeight: 19, textAlign: 'center', maxWidth: 200 }, + time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, + notice: { fontSize: 14.5, lineHeight: 20 }, + caption: { borderWidth: 1, borderRadius: radius.card, paddingHorizontal: 16, paddingVertical: 14, gap: 6 }, + centredCard: { alignItems: 'center', paddingVertical: 20, gap: 8 }, + captionTitle: { fontSize: 16.5, textAlign: 'center' }, + captionText: { fontSize: 15, lineHeight: 21 }, + centred: { textAlign: 'center' }, + captionLabel: { fontSize: 13.5 }, + speechText: { fontSize: 17, lineHeight: 25 }, + composer: { borderWidth: 1, borderRadius: radius.card, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 10, gap: 6 }, + guidance: { fontSize: 14, lineHeight: 19 }, + input: { minHeight: 150, fontSize: 17, lineHeight: 24, fontFamily: fontFamily.regular, textAlignVertical: 'top', paddingTop: 4 }, + words: { fontSize: 13, alignSelf: 'flex-end', fontVariant: ['tabular-nums'] }, + controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-evenly', paddingTop: 12 }, + keyboardBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 18, paddingVertical: 8, borderTopWidth: StyleSheet.hairlineWidth }, + keyboardClock: { fontSize: 17, fontVariant: ['tabular-nums'] }, +}); diff --git a/apps/mobile/src/components/document.tsx b/apps/mobile/src/components/document.tsx index e24ca92eb..6af82b916 100644 --- a/apps/mobile/src/components/document.tsx +++ b/apps/mobile/src/components/document.tsx @@ -22,7 +22,22 @@ const BODY = 15.5; const PINNED_HEIGHT = 54; /** The scrolling page under the header, leaving room for the pinned actions. */ -export function DocumentScreen({ header, children, actions, notice, testID }: { header: ReactNode; children: ReactNode; actions?: ReactNode; notice?: string; testID?: string }) { +export function DocumentScreen({ + header, + children, + actions, + actionWidths, + notice, + testID, +}: { + header: ReactNode; + children: ReactNode; + actions?: ReactNode; + /** How the pinned buttons share the width, such as [1, 1.4]; equally when absent. */ + actionWidths?: number[]; + notice?: string; + testID?: string; +}) { const { colors } = useTheme(); const insets = useSafeAreaInsets(); const bottom = Math.max(insets.bottom - 6, 14); @@ -45,9 +60,9 @@ export function DocumentScreen({ header, children, actions, notice, testID }: { ) : null} {actions ? ( - {/* The pinned buttons share the width equally, as in the mock, within the document's column on iPad. */} + {/* The pinned buttons share the width equally unless told otherwise, as in the mocks, within the document's column on iPad. */} - {Children.map(unwrapFragment(actions), (action) => (action ? {action} : null))} + {Children.map(unwrapFragment(actions), (action, index) => (action ? {action} : null))} ) : null} diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index bc26f7788..d8c07b07f 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,6 +1,6 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. Attack, Ask, Crossfire and Timer are in the app; the rest follow. */ +/** The Practice tools, in the website's order. All of them are in the app. */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, diff --git a/apps/mobile/src/components/round-controls.tsx b/apps/mobile/src/components/round-controls.tsx index 93128bac2..6d19cfd32 100644 --- a/apps/mobile/src/components/round-controls.tsx +++ b/apps/mobile/src/components/round-controls.tsx @@ -70,10 +70,17 @@ export function SideControl({ icon, label, onPress, disabled, testID }: { icon: } /** The one main action of a live screen, in the gradient, with its label under it. */ -export function MainRoundControl({ icon, label, onPress, testID }: { icon: IconName; label: string; onPress: () => void; testID?: string }) { +export function MainRoundControl({ icon, label, onPress, disabled, testID }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean; testID?: string }) { return ( - [styles.control, pressed && styles.pressed]}> - + [styles.control, pressed && styles.pressed]}> + {/* The gradient carries the rounding, so the circle's shadow is not clipped. */} @@ -83,7 +90,7 @@ export function MainRoundControl({ icon, label, onPress, testID }: { icon: IconN - + {label} @@ -101,10 +108,13 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.6 }, control: { alignItems: 'center', gap: 6, minWidth: 88 }, controlLabel: { fontSize: 13.5 }, + // Long labels such as "Your Con R1 is next" wrap under the button instead of being cut. + mainLabel: { maxWidth: 150, textAlign: 'center' }, sideCircle: { width: 56, height: 56, borderRadius: 28, alignItems: 'center', justifyContent: 'center' }, sideBorder: { borderWidth: 1 }, mainHalo: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(56,189,248,0.14)' }, mainGradient: { borderRadius: 42 }, + mainDisabled: { opacity: 0.45 }, mainIcon: { position: 'relative' }, mainCircle: { width: 84, diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx new file mode 100644 index 000000000..21855dfea --- /dev/null +++ b/apps/mobile/src/components/sparring-views.tsx @@ -0,0 +1,381 @@ +import type { SparringScorecard, SparringSpeech } from '@debatelab/core/ai/schemas/sparring'; +import { formatRoundTime } from '@debatelab/core/sparring/formats'; +import type { SparringSession } from '@debatelab/core/sparring/session'; +import type { TFunction } from 'i18next'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, SecondaryButton, TextButton } from './buttons'; +import { Bullets, DocSection, DocumentScreen, Paragraph } from './document'; +import { DocumentHeader } from './document-header'; +import { SegmentedControl } from './segmented-control'; +import { Text } from './text'; + +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { confirmDestructive } from '@/lib/confirm'; +import { shareText } from '@/lib/export'; +import { deleteSparringRound } from '@/lib/sparring'; +import { mainActionGradient, radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +const CATEGORY_MAX = 25; + +/** The ballot's headline, as the website's scorecard words it. */ +export function winnerText(t: TFunction, winner: SparringScorecard['winner']): string { + return winner === 'user' ? t('sparring.scorecard.userWon') : winner === 'ai' ? t('sparring.scorecard.opponentWon') : t('sparring.scorecard.tie'); +} + +/** The scorecard as plain text for the share sheet. */ +function scorecardText(t: TFunction, session: SparringSession, scorecard: SparringScorecard): string { + return [ + winnerText(t, scorecard.winner), + session.motion, + `${t('sparring.scorecard.yourScore')}: ${scorecard.userScore}/100 · ${t('sparring.scorecard.opponentScore')}: ${scorecard.aiScore}/100`, + scorecard.categories.map((category) => `${t(`sparring.category.${category.name}`)}: ${t('mobile.sparring.versus', { user: category.userScore, opponent: category.aiScore })}`).join('\n'), + `${t('sparring.scorecard.verdict')}\n${scorecard.verdict}`, + `${t('sparring.scorecard.overallFeedback')}\n${scorecard.overallFeedback}`, + ].join('\n\n'); +} + +/** + * A judged round as a document, for a round just finished and a saved one + * reopened: share, delete with confirmation, Replay for the transcript, and + * another round. `onDeleted` runs only if the screen is still open for the + * same account. + */ +export function SparringScorecardScreen({ + session, + from, + notice, + onRestart, + onDeleted, +}: { + session: SparringSession & { scorecard: SparringScorecard }; + from: string | undefined; + notice?: string; + onRestart: () => void; + onDeleted: () => void; +}) { + const { t } = useTranslation(); + const { session: auth } = useAuth(); + const userId = auth?.user.id ?? null; + const [transcriptOpen, setTranscriptOpen] = useState(false); + const [failed, setFailed] = useState(''); + const live = useRef(true); + const owner = useRef(userId); + useEffect(() => { + owner.current = userId; + }, [userId]); + useEffect(() => { + live.current = true; + return () => { + live.current = false; + }; + }, []); + + const remove = async () => { + const forUser = userId; + if (!forUser) return; + const confirmed = await confirmDestructive({ + title: t('historyPage.deleteSparringTitle'), + message: t('historyPage.deleteSparringDesc'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed || !live.current || owner.current !== forUser) return; + try { + await deleteSparringRound(forUser, session.id); + if (live.current && owner.current === forUser) onDeleted(); + } catch (cause) { + console.warn('Could not delete the round:', cause instanceof Error ? cause.message : cause); + if (live.current) setFailed(t('historyPage.sparringDeleteFailed')); + } + }; + + return ( + void shareText(winnerText(t, session.scorecard.winner), scorecardText(t, session, session.scorecard)).catch(() => {})} + actions={[{ key: 'delete', label: t('common.delete'), symbol: 'trash', icon: 'trash', destructive: true, onPress: () => void remove() }]} + /> + } + notice={failed || notice} + // The mock gives the next round the wider button. + actionWidths={[1, 1.4]} + actions={ + <> + setTranscriptOpen((open) => !open)} floating testID="sparring-replay" /> + + + }> + + + ); +} + +/** + * The round review from mock 06: both totals, the four categories as bars + * instead of the website's radar chart, and the judge's verdict. Below them + * come the website's category notes, key moments, what to work on next and + * the overall feedback, and the transcript when Replay opens it. + */ +export function SparringScorecardView({ session, scorecard, transcriptOpen }: { session: SparringSession; scorecard: SparringScorecard; transcriptOpen: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + + {t('sparring.scorecard.eyebrow')} + + + {winnerText(t, scorecard.winner)} + + + {session.motion} + + + + + + + {t('sparring.scorecard.yourScore')} + + {scorecard.userScore} + + + + + {t('sparring.scorecard.opponentScore')} + + + {scorecard.aiScore} + + + + + + + + {t('sparring.scorecard.categoryBalance')} + + + {t('sparring.scorecard.categoryDescription')} + + + {scorecard.categories.map((category) => ( + + + + {t(`sparring.category.${category.name}`)} + + + {t('mobile.sparring.versus', { user: category.userScore, opponent: category.aiScore })} + + + + + {/* The opponent's score is the tick on the same bar. */} + + + + ))} + + + + + {t('sparring.scorecard.verdict')} + + + {scorecard.verdict} + + + + + {scorecard.categories.map((category) => ( + + + {t(`sparring.category.${category.name}`)} + {category.name === 'Delivery' ? {` ${t('sparring.scorecard.writtenOnly')}`} : null} + + + {category.feedback} + + + ))} + + + + {scorecard.keyMoments.length > 0 ? : {t('sparring.scorecard.noKeyMoments')}} + + + {scorecard.improvementAreas.length > 0 ? ( + + ) : ( + {t('sparring.scorecard.noImprovementAreas')} + )} + + + + + + {transcriptOpen ? ( + + + + ) : null} + + ); +} + +/** Every speech of a round, who gave it, and the time it took, as the website's transcript. */ +export function SparringTranscript({ speeches }: { speeches: SparringSpeech[] }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (speeches.length === 0) { + return ( + + {t('sparring.round.transcriptEmpty')} + + ); + } + return ( + + {speeches.map((speech, index) => ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]} testID="sparring-speech"> + + + {`${speech.speaker === 'user' ? t('sparring.user') : t('sparring.opponent')} · ${t(`sparring.turns.${speech.turnId}`, { defaultValue: speech.role })}`} + + + {speech.speaker === 'ai' + ? t('sparring.round.estimatedTimeUsed', { time: formatRoundTime(speech.timeUsedSeconds) }) + : t('sparring.round.timeUsed', { time: formatRoundTime(speech.timeUsedSeconds) })} + + + + + ))} + + ); +} + +/** + * The flow button's sheet: notes the student keeps during the round, and the + * transcript so far. The scroll view is the sheet's root, as every sheet's is. + */ +export function RoundNotesSheet({ + visible, + notes, + onChangeNotes, + speeches, + onClose, +}: { + visible: boolean; + notes: string; + onChangeNotes: (value: string) => void; + speeches: SparringSpeech[]; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const [view, setView] = useState<'notes' | 'transcript'>('notes'); + return ( + + + + + {t('mobile.sparring.notes')} + + + + + value={view} + onChange={setView} + options={[ + { value: 'notes', label: t('mobile.sparring.notes') }, + { value: 'transcript', label: t('sparring.round.transcript') }, + ]} + /> + {view === 'notes' ? ( + + ) : ( + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + scorecard: { gap: 12 }, + heading: { gap: 2, marginBottom: 2 }, + eyebrow: { fontSize: 15 }, + title: { fontSize: 30, lineHeight: 36, letterSpacing: -0.8 }, + motion: { fontSize: 15, lineHeight: 20, marginTop: 2 }, + totals: { flexDirection: 'row', gap: 10 }, + total: { flex: 1, borderRadius: radius.card + 2, paddingHorizontal: 15, paddingVertical: 13, overflow: 'hidden', gap: 2 }, + totalOwn: { borderWidth: 0 }, + totalBorder: { borderWidth: 1 }, + totalLabel: { fontSize: 14.5 }, + onGradient: { color: '#ffffff' }, + totalValue: { fontSize: 38, lineHeight: 44, letterSpacing: -1, fontVariant: ['tabular-nums'] }, + card: { borderWidth: 1, borderRadius: radius.card + 1, paddingHorizontal: 16, paddingVertical: 15, gap: 10 }, + cardHead: { gap: 2, marginBottom: 2 }, + cardTitle: { fontSize: 16.5 }, + cardDescription: { fontSize: 14, lineHeight: 19 }, + category: { gap: 6 }, + categoryLine: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, + categoryName: { fontSize: 15.5, flexShrink: 1 }, + categoryScores: { fontSize: 15, fontVariant: ['tabular-nums'] }, + track: { height: 8, borderRadius: 4 }, + fill: { position: 'absolute', left: 0, top: 0, bottom: 0, borderRadius: 4 }, + tick: { position: 'absolute', top: -3, width: 2.5, height: 14, borderRadius: 1, marginLeft: -1 }, + verdict: { fontSize: 15.5, lineHeight: 22.5 }, + note: { gap: 3 }, + noteTitle: { fontSize: 15.5 }, + noteHint: { fontSize: 13.5 }, + noteText: { fontSize: 15, lineHeight: 21.5 }, + speeches: { gap: 0 }, + speech: { gap: 6, paddingVertical: 10 }, + speechHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, + speaker: { fontSize: 15, flexShrink: 1 }, + used: { fontSize: 13.5, fontVariant: ['tabular-nums'] }, + sheet: { paddingHorizontal: 20, paddingTop: 18, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + sheetHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + sheetTitle: { fontSize: 22 }, + notesInput: { minHeight: 260, borderWidth: 1, borderRadius: radius.field, padding: 14, fontSize: 16.5, lineHeight: 23, fontFamily: fontFamily.regular, textAlignVertical: 'top' }, +}); diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index d8811744f..c72908d3f 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -26,7 +26,7 @@ export const KIND_ICONS: Record = { export type OpenedFrom = 'debate' | 'prepare' | 'practice'; /** - * Where a saved item opens. Cases and attacks open read-only as documents; + * Where a saved item opens. Cases, attacks and Sparring rounds open read-only as documents; * the Prepare tools not in the app yet open their "on its way" screen, and * Practice work opens the Practice tab. */ @@ -40,6 +40,8 @@ export function workHref(item: DebateItem, from: OpenedFrom): Href { case 'factoids': case 'flowchart': return { pathname: '/coming/[tool]', params: { tool: item.kind } }; + case 'sparring': + return { pathname: '/sparring/[id]', params: { id: item.id, from } }; default: return '/practice'; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 97fa167b9..2d0b95f94 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -221,13 +221,14 @@ export async function fetchRecentWork( const lists = await Promise.all([ ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { - // The website lists a round under its motion and the side the student spoke, ordered by its last update. + // The website lists a judged round under its motion and the side the student spoke, ordered by its last update. const { data, error } = await supabase .from('sparring_sessions') .select('id,motion,updated_at') .eq('user_id', userId) .eq('debate_id', debateId) .eq('user_side', side) + .eq('status', 'complete') .order('updated_at', { ascending: false }) .limit(limit); if (error) throw new Error(error.message); diff --git a/apps/mobile/src/lib/keep-if-same.ts b/apps/mobile/src/lib/keep-if-same.ts new file mode 100644 index 000000000..1c14e942e --- /dev/null +++ b/apps/mobile/src/lib/keep-if-same.ts @@ -0,0 +1,4 @@ +/** Focus refreshes usually reread unchanged data; returning the previous value keeps its identity, so nothing re-renders. */ +export function keepIfSame(previous: T, next: T): T { + return JSON.stringify(previous) === JSON.stringify(next) ? previous : next; +} diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts new file mode 100644 index 000000000..f471f08e1 --- /dev/null +++ b/apps/mobile/src/lib/sparring.ts @@ -0,0 +1,139 @@ +import { + SparringInputSchema, + SparringScoreResponseSchema, + SparringTurnResponseSchema, + type SparringRespondInput, + type SparringScorecard, + type SparringScoreInput, + type SparringSide, + type SparringTurnResponse, +} from '@debatelab/core/ai/schemas/sparring'; +import { parseSavedSparringRows, SPARRING_SESSION_COLUMNS, type SparringSession } from '@debatelab/core/sparring/session'; +import * as Crypto from 'expo-crypto'; + +import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; +import type { FeatureConfig } from './feature-config'; +import { sessionSlot } from './session-memory'; +import { supabase } from './supabase'; + +/** + * Typed Sparring, sent and saved as the website does it: generateSparringTurn + * and judgeSparringSession in src/lib/ai/client.ts for the requests, and the + * sparring store (src/lib/stores/sparring-store.ts) for the rest. The server + * saves the round when it judges it; the app then files it in its debate and + * reads, reopens and deletes saved rounds. Voice rounds need the development + * build, so the requests never ask for a spoken reply. + */ + +/** The model answered, but not in the shape the round needs. */ +export class SparringResponseError extends Error { + constructor(message: string) { + super(message); + this.name = 'SparringResponseError'; + } +} + +interface RequestOptions { + config: FeatureConfig; + /** The debate the round belongs to; null for a one-off round. */ + debateId: string | null; + signal?: AbortSignal; +} + +/** The website's request body. A live round stays transcript-only, so its debate context carries no sources or results. */ +function requestBody(input: SparringRespondInput | SparringScoreInput, options: RequestOptions) { + const parsed = SparringInputSchema.safeParse(input); + if (!parsed.success) throw new AIError('failed', 'Invalid sparring request.'); + return { + feature: 'sparring', + input: parsed.data, + provider: PROVIDER, + model: options.config.model, + temperature: options.config.temperature, + reasoningEffort: options.config.reasoningEffort, + // Servers from before Current Debate context read only this field. + sourceIds: [], + ...(options.debateId ? { debateContext: { debateId: options.debateId, side: input.userSide, sourceIds: [], outputs: [] } } : {}), + }; +} + +/** The opponent's speech for the current turn, with the token the server signed it with. */ +export function sparringTurn(input: SparringRespondInput, options: RequestOptions): Promise { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output } = await functionOutput(response); + const parsed = SparringTurnResponseSchema.safeParse(output); + if (!parsed.success) throw new SparringResponseError('AI response did not match the expected sparring turn schema.'); + return parsed.data.speech; + }); +} + +/** + * The judge's scorecard. The server saves the judged round to + * sparring_sessions before it answers, and answers a repeated request with + * the scorecard it saved. + */ +export function judgeSparring(input: SparringScoreInput, options: RequestOptions): Promise { + return postFunction('ai', requestBody(input, options), options.signal, async (response) => { + const { output } = await functionOutput(response); + const parsed = SparringScoreResponseSchema.safeParse(output); + if (!parsed.success) throw new SparringResponseError('AI response did not match the expected sparring score schema.'); + return parsed.data.scorecard; + }); +} + +/** Files a judged round in its debate under the side the student spoke, as the store's attachSessionToDebate. */ +export async function attachSparring(id: string, debateId: string, side: SparringSide): Promise { + const { error } = await supabase.rpc('attach_to_debate', { p_kind: 'sparring', p_id: id, p_debate_id: debateId, p_side: side }); + if (error) throw new Error(error.message); +} + +/** A saved, judged round; null when there is none or it does not check out. */ +export async function fetchSparringRound(userId: string, id: string): Promise { + const { data, error } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw new Error(error.message); + return data ? parseSavedSparringRows([data])[0] ?? null : null; +} + +export interface SparringRoundSummary { + id: string; + motion: string; + userSide: SparringSide; + updatedAt: number; +} + +/** + * The judged rounds in one debate, or outside every debate when `debateId` is + * null, newest first, as the website's history lists them. + */ +export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number): Promise { + let query = supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('status', 'complete'); + query = debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null); + const { data, error } = await query.order('updated_at', { ascending: false }).limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; motion: string; user_side: SparringSide; updated_at: string }[]).map((row) => ({ + id: row.id, + motion: row.motion, + userSide: row.user_side, + updatedAt: new Date(row.updated_at).getTime(), + })); +} + +export async function deleteSparringRound(userId: string, id: string): Promise { + const { error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id); + if (error) throw new Error(error.message); +} + +/** A new round's id. The server takes it as the saved row's id, so it must be a UUID. */ +export function newSparringId(): string { + return Crypto.randomUUID(); +} + +/** + * The unfinished round, kept for the session as the website keeps it in the + * tab: leaving the screen and coming back continues it. It belongs to the + * signed-in account and is forgotten when that changes. + */ +export const sparringDraft = sessionSlot(() => null); + +/** Notes the student takes during a round, by round id, for the session. */ +export const sparringNotes = sessionSlot>(() => ({})); diff --git a/apps/mobile/src/lib/timer-signals.ts b/apps/mobile/src/lib/timer-signals.ts index 85e0b215b..8e259bf36 100644 --- a/apps/mobile/src/lib/timer-signals.ts +++ b/apps/mobile/src/lib/timer-signals.ts @@ -38,6 +38,11 @@ export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void }, [beep, doubleBeep]); } +/** A light tap when the student's turn starts in a live round, so they know without looking. */ +export function tapTurnStart() { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); +} + /** Keeps the screen on while a timer runs, and lets it sleep again when it stops or the screen closes. */ export function useKeepAwakeWhile(active: boolean) { useEffect(() => { diff --git a/apps/mobile/src/lib/timer-signals.web.ts b/apps/mobile/src/lib/timer-signals.web.ts index ce2777ac6..707006960 100644 --- a/apps/mobile/src/lib/timer-signals.web.ts +++ b/apps/mobile/src/lib/timer-signals.web.ts @@ -13,4 +13,10 @@ export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void }; } +/** Counts the turn-start taps on `window` for the screenshot script. */ +export function tapTurnStart() { + const target = window as unknown as { __debatelabTurnTaps?: number }; + target.__debatelabTurnTaps = (target.__debatelabTurnTaps ?? 0) + 1; +} + export function useKeepAwakeWhile(_active: boolean) {} diff --git a/src/locales/en.json b/src/locales/en.json index fbf255a5b..5617e8ea4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2534,6 +2534,19 @@ }, "crossfire": { "voiceLater": "Coming with the app build." + }, + "sparring": { + "chooseSide": "Which side are you on?", + "turnOf": "Turn {{current}} of {{total}}, {{pressure}}", + "yourNext": "Your {{turn}} is next", + "interrupted": "The request stopped when you left the round. Try again to carry on.", + "notes": "Notes", + "notesPlaceholder": "Their claims, your answers, anything to come back to. Notes stay until you close the app or sign out.", + "done": "Done", + "resume": "Resume", + "replay": "Replay", + "versus": "{{user}} vs {{opponent}}", + "roundsFailed": "Could not load your sparring rounds." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 48f66e69f..d6f3c4c76 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2534,6 +2534,19 @@ }, "crossfire": { "voiceLater": "Llegará con la versión de la app." + }, + "sparring": { + "chooseSide": "¿Qué postura defiendes?", + "turnOf": "Turno {{current}} de {{total}}, {{pressure}}", + "yourNext": "Después te toca: {{turn}}", + "interrupted": "La solicitud se detuvo al salir de la ronda. Vuelve a intentarlo para continuar.", + "notes": "Notas", + "notesPlaceholder": "Sus argumentos, tus respuestas y lo que quieras retomar. Las notas se guardan hasta que cierres la app o la sesión.", + "done": "Listo", + "resume": "Reanudar", + "replay": "Repasar", + "versus": "{{user}} vs. {{opponent}}", + "roundsFailed": "No se pudieron cargar tus rondas de sparring." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 8a9f247fa..0cba35ca4 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2534,6 +2534,19 @@ }, "crossfire": { "voiceLater": "Az alkalmazás következő verziójával érkezik." + }, + "sparring": { + "chooseSide": "Melyik oldalon állsz?", + "turnOf": "{{current}}. kör a(z) {{total}} közül, {{pressure}}", + "yourNext": "Utána te jössz: {{turn}}", + "interrupted": "A kérés leállt, amikor kiléptél a fordulóból. Próbáld újra a folytatáshoz.", + "notes": "Jegyzetek", + "notesPlaceholder": "Az ellenfél állításai, a válaszaid, bármi, amire visszatérnél. A jegyzetek az app bezárásáig vagy a kijelentkezésig maradnak.", + "done": "Kész", + "resume": "Folytatás", + "replay": "Visszanézés", + "versus": "{{user}} – {{opponent}}", + "roundsFailed": "Nem sikerült betölteni a vitapárbaj-fordulóidat." } } } From b973c21d55b38a4935088f9ffa08cb1e73b7178a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:45:52 +0000 Subject: [PATCH 3/8] Screenshot the Sparring round and describe it in the app guide The screenshot script plays a Lincoln-Douglas round as Con from fixed outputs in English and Hungarian, holding the opponent and the judge for their shots, and checks the turn-start taps and the running clock. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 3 +- apps/mobile/scripts/screenshots.ts | 139 +++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a77638245..8b9cb854c 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,7 +16,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx`, `attack/[id].tsx`, `crossfire.tsx` and `timer.tsx` (a full-screen modal), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` and `(practice)/soon/[tool].tsx` stand in for tools not in the app yet. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; the form sheets `debates.tsx` (switcher), `new-debate.tsx`, `delete-account.tsx`, `add-link.tsx`, `new-note.tsx` and `source/[id].tsx`; and `ask.tsx`, `generate.tsx`, `case/[id].tsx`, `attack/index.tsx`, `attack/[id].tsx`, `crossfire.tsx`, `sparring/index.tsx`, `sparring/[id].tsx` and `timer.tsx` (a full-screen modal), full screen above the tabs. All are declared in the root `_layout.tsx`. `(prepare)/coming/[tool].tsx` stands in for Prepare tools not in the app yet; every Practice tool is in the app, so `(practice)/soon/[tool].tsx` is no longer reached from the Practice tab. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. - `sources.ts` mirrors the session store's `addSource` and `removeSource` and `src/lib/source-import.ts`. The Library always shows the Current Debate, also in one-off mode, as on the website. Adding a file (the website parses files in the browser) and Scan pages are not in the app yet. @@ -24,6 +24,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Generate and Attack mirror `generateCase` and `attackCase` in `src/lib/ai/client.ts` and the session store's `recordResult`: `results.ts` sends the requests through `ai-request.ts`, checks the output with the website's transform (moved to `packages/core/src/ai/generate-output.ts`), keeps researched sources in the debate's Auto-saved folder (`packages/core/src/ai/discovered-sources.ts`), saves the server's source rankings onto the Library sources a result used (`packages/core/src/ai/source-assessments.ts`, also for Ask's whole answers), says when the server shortened the input, and saves rows to `cases` and `attacks`. `motion.ts` and `motion-gate.ts` are the website's motion gate. `open-results.ts` holds results made in this session, so one-off work and failed saves still open as documents. It and the last Generate and Attack forms live in `session-memory.ts`, which the auth provider clears on every account change, including sign-out. `components/document.tsx` draws results as documents (mock 03); Brainstorm and Factoids reuse it. Export renders `export-html.ts` to a PDF with expo-print and shares it (`export.ts`); the web preview cannot print, so `export.web.ts` keeps the HTML and copied text on `window` for the screenshot script. Word export and Google Docs are not in the app. - Timer mirrors `src/pages/TimerPage.tsx`: `timer.ts` holds the formats and a clock made of start timestamps, never counted ticks, so it is right after the app returns from the background (`timer.test.ts`). `timer-signals.ts` plays the bundled beeps in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while a speech runs; `timer-signals.web.ts` only records the signals for the screenshot script. Lock-screen timers wait for the development build (`TODO(live-activity)`). - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. +- Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens session-only notes and the transcript, as the Flowchart is not in the app. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 28fac823e..f085c83dd 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -40,6 +40,11 @@ * each turn, signal and debrief with fixed text in the run's language, and * answers the crossfire write locally. * + * Sparring screenshots never call a model and never save: the page answers + * each opponent turn and the judge with fixed text in the run's language + * (holding them open for the "opponent" and "judging" shots), and answers + * the attach_to_debate call locally. The run checks the turn-start taps. + * * The Timer runs on Playwright's clock, so its minutes pass at once, and the * run checks that it signalled one beep at the last minute and two at time up. */ @@ -546,6 +551,138 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la await page.close(); } +// A made-up Lincoln-Douglas round as Con, in English and Hungarian, and a scorecard whose totals match its categories, as the client checks. +const SPARRING_TEXT = { + en: { + opponent: [ + 'Private cars fill the centre with fumes that children breathe on their way to school. Closing the centre to them is the fastest way to clean that air, and every city that tried it kept the ban.', + 'My opponent says shops will suffer, but footfall rises when streets are calm. People who walk stop more often and stay longer than people looking for a parking space.', + 'The night-shift worker is covered: permits for carers and early shifts already exist. What remains is a choice between cleaner air for everyone and convenience for a few.', + ], + speeches: [ + 'A ban punishes the people with the fewest choices. The nurse who drives in at five a.m. has no bus at that hour, and permits only help those who know how to apply.', + 'Cleaner air matters, but low-emission zones get most of the benefit without shutting anyone out. Charge the most polluting cars and leave the centre open.', + ], + verdict: "Your second speech answered the night-shift worker case directly. Evidence was thin: name the transit data, don't just say it exists.", + feedback: ['The burden on shift workers was clear and well framed.', 'You asserted that low-emission zones work without a source.', 'Delivery read clearly, with short and confident sentences.', 'You chose the right clash and stayed on it.'], + overall: 'A disciplined round. Lead with your strongest example and back each claim with one named source.', + moments: ['Turning the permit system into an access problem.'], + next: ['Bring one figure for how many workers start before the buses run.'], + }, + hu: { + opponent: [ + 'A magánautók kipufogógázzal töltik meg a belvárost, amelyet a gyerekek iskolába menet belélegeznek. A belváros lezárása a leggyorsabb módja a levegő megtisztításának, és minden város megtartotta a tilalmat, amely kipróbálta.', + 'Az ellenfelem szerint a boltok veszítenek, de a forgalom nő, ha az utcák csendesek. A gyalogosok gyakrabban megállnak és tovább maradnak, mint akik parkolóhelyet keresnek.', + 'Az éjszakás dolgozókra is gondoltunk: a gondozóknak és a korai műszakosoknak már ma is jár engedély. A választás a tisztább levegő mindenkinek és néhányak kényelme között van.', + ], + speeches: [ + 'A tilalom azokat bünteti, akiknek a legkevesebb választásuk van. A hajnali ötkor munkába autózó ápolónak abban az órában nincs busza, az engedély pedig csak annak segít, aki tudja, hogyan kérje.', + 'A tiszta levegő fontos, de az alacsony kibocsátású övezetek kizárás nélkül hozzák az előnyök nagy részét. Fizessenek a legjobban szennyező autók, a belváros maradjon nyitva.', + ], + verdict: 'A második beszéded közvetlenül válaszolt az éjszakás dolgozó esetére. A bizonyítékok gyengék voltak: nevezd meg a közlekedési adatokat, ne csak állítsd, hogy léteznek.', + feedback: ['A műszakban dolgozók terhét világosan és jól keretezted.', 'Forrás nélkül állítottad, hogy az alacsony kibocsátású övezetek működnek.', 'Az előadás világos volt, rövid és határozott mondatokkal.', 'A megfelelő ütközési pontot választottad, és kitartottál mellette.'], + overall: 'Fegyelmezett forduló. Kezdd a legerősebb példáddal, és minden állítást támassz alá egy megnevezett forrással.', + moments: ['Az engedélyrendszert hozzáférési problémává fordítottad.'], + next: ['Hozz egy számot arról, hány dolgozó kezd a buszok indulása előtt.'], + }, +} as const; + +const SPARRING_CATEGORIES = [['Logic', 21, 18], ['Evidence', 15, 19], ['Delivery', 22, 17], ['Strategy', 20, 17]] as const; + +/** Fakes Sparring's AI calls and its debate filing for this page (see the header). `hold` keeps the next answer of that kind open until it is released. */ +async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) { + const text = SPARRING_TEXT[language]; + const held: Record<'respond' | 'score', (() => void) | null> = { respond: null, score: null }; + const holding = { respond: false, score: false }; + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { + const body = route.request().postDataJSON() as { feature?: string; input?: { action?: 'respond' | 'score'; speeches?: { speaker: string }[] } } | null; + if (body?.feature !== 'sparring') return route.abort(); + const action = body.input?.action ?? 'respond'; + if (holding[action]) await new Promise((resolve) => { held[action] = resolve; }); + let output: unknown; + if (action === 'respond') { + const index = (body.input?.speeches ?? []).filter((speech) => speech.speaker === 'ai').length; + output = { action, speech: { content: text.opponent[index % text.opponent.length], strategy: 'Press the access argument.', targetedPoints: [], integrityToken: 'screenshot-integrity-token-0000000000000000' } }; + } else { + const categories = SPARRING_CATEGORIES.map(([name, userScore, aiScore], index) => ({ name, userScore, aiScore, feedback: text.feedback[index] })); + output = { + action, + scorecard: { + userScore: categories.reduce((sum, item) => sum + item.userScore, 0), + aiScore: categories.reduce((sum, item) => sum + item.aiScore, 0), + categories, + overallFeedback: text.overall, + keyMoments: [...text.moments], + improvementAreas: [...text.next], + winner: 'user', + verdict: text.verdict, + }, + }; + } + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output } }); + }); + await page.route(`${REST}/rpc/attach_to_debate`, (route) => route.fulfill({ status: 204, body: '' })); + return { + hold: (action: 'respond' | 'score') => { holding[action] = true; }, + release: (action: 'respond' | 'score') => { + holding[action] = false; + held[action]?.(); + held[action] = null; + }, + }; +} + +/** Sparring from the Practice tab: the setup, the opponent's turn and yours, judging, and the scorecard, all from fixed outputs. */ +async function shootSparringScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const text = SPARRING_TEXT[language === 'hu' ? 'hu' : 'en']; + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} sparring`); + await pinSettings(page, language); + const fake = await fakeSparring(page, language === 'hu' ? 'hu' : 'en'); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-sparring').click({ timeout: 30_000 }); + await page.getByTestId('sparring-setup').waitFor({ timeout: 15_000 }); + // The motion comes from the Current Debate, so the round attaches to it without the motion question. + await page.waitForFunction(() => (document.querySelector('[data-testid="sparring-motion"]') as HTMLTextAreaElement | null)?.value.trim()); + await page.getByTestId('sparring-format').click(); + await page.getByRole('menuitem', { name: 'Lincoln-Douglas' }).click(); + await settle(page); + await shoot(page, `${prefix}-sparring-setup`); + + fake.hold('respond'); + await page.getByRole('button', { name: /^(Start round|Forduló indítása|Iniciar ronda)$/ }).click(); + await page.getByRole('menuitem', { name: /^(Con|Ellene|En contra)$/ }).click(); + await page.getByTestId('sparring-thinking').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-sparring-opponent`); + fake.release('respond'); + + for (const [index, speech] of text.speeches.entries()) { + await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); + await page.getByTestId('sparring-input').fill(speech); + if (index === 0) { + await page.getByTestId('sparring-input').blur(); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-sparring-your-turn`); + // The student's clock runs from the moment the turn starts. + await page.getByTestId('sparring-time').filter({ hasText: /^6:5\d$/ }).waitFor({ timeout: 5_000 }); + } + if (index === text.speeches.length - 1) fake.hold('score'); + await page.getByTestId('sparring-main').click(); + } + await page.getByTestId('sparring-judging').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-sparring-judging`); + fake.release('score'); + + await page.getByTestId('sparring-scorecard').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-sparring-scorecard`); + const taps = await page.evaluate(() => (window as unknown as { __debatelabTurnTaps?: number }).__debatelabTurnTaps ?? 0); + if (taps !== text.speeches.length) problems.push(`${prefix} sparring: expected ${text.speeches.length} turn-start taps, got ${taps}`); + await page.close(); +} + /** * The Timer from the Practice tab: idle, running, and after time up. The page's * clock is Playwright's, so minutes pass at once; the web preview records the @@ -730,6 +867,8 @@ async function main() { shots += 3; await shootCrossfireScreens(signedIn, prefix, language, problems); shots += 3; + await shootSparringScreens(signedIn, prefix, language, problems); + shots += 5; await signedIn.close(); } } From 219657eae569ff1a8f78ad62934010ac74f1c735 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:48:00 +0000 Subject: [PATCH 4/8] Forget an open Sparring round when the account changes Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/sparring/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 8c4df77da..876777603 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -106,8 +106,14 @@ export default function SparringScreen() { const live = useRef(true); const owner = useRef(userId); + // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { + if (owner.current === userId) return; owner.current = userId; + requestRef.current?.abort(); + sessionRef.current = null; + setSession(null); + setRequest({ status: 'idle' }); }, [userId]); /** The one way the round changes: kept for the session as it changes, as the website keeps it in the tab. */ From 181f6d8890fef3865670fcef31209c79e8281ffc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 16:49:08 +0000 Subject: [PATCH 5/8] Ignore a second Submit tap once the speech has gone in Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/sparring/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 876777603..f5ef9c9e9 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -288,7 +288,8 @@ export default function SparringScreen() { const submit = () => { const latest = sessionRef.current; - if (!latest || !userId) return; + // A second tap after the speech went in lands on the opponent's turn; it is not a rejected speech. + if (!latest || !userId || latest.phase !== 'round' || currentSparringTurn(latest)?.speaker !== 'user') return; Keyboard.dismiss(); const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId }); if (!next) { From b4e8ac2829665fa4ef2a943be79c3237cc996622 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 11:53:34 +0000 Subject: [PATCH 6/8] Keep Practice's rounds to their debate and root the notes sheet in SheetScrollView Practice now numbers its loads and files a failure under the debate it was read for, as Prepare does, so a slow load or an error for one debate never shows under another. The round's notes sheet uses SheetScrollView like every sheet, and the opponent and judge requests read the Sparring settings under the request's abort, so leaving while they load sends nothing. Co-Authored-By: Claude Opus 5.5 --- .../src/app/(tabs)/(practice)/practice.tsx | 43 +++++++++++-------- apps/mobile/src/app/sparring/index.tsx | 10 ++--- apps/mobile/src/components/sparring-views.tsx | 19 +++----- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 5f7c65356..1a9a9d2d2 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -1,5 +1,5 @@ import { useFocusEffect, useRouter, type Href } from 'expo-router'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; @@ -39,32 +39,39 @@ export default function PracticeScreen() { const userId = session?.user.id ?? null; const { current } = useDebates(); const debateId = current?.id ?? null; - const [rounds, setRounds] = useState<{ debateId: string | null; items: SparringRoundSummary[] } | null>(null); - const [failed, setFailed] = useState(false); + // Each load belongs to the debate it was read for, so another debate's rounds, or its failure, never show under this one. + const [rounds, setRounds] = useState<{ debateId: string | null; items: SparringRoundSummary[] | null; failed: boolean } | null>(null); + + // Every load takes a number; only the newest may write, so a slow load for another debate never replaces this one's. + const loads = useRef(0); + const load = useCallback(async () => { + if (!userId) return; + const forDebate = debateId; + const request = ++loads.current; + const isCurrent = () => loads.current === request; + try { + const items = await fetchSparringRounds(userId, forDebate, ROUNDS_LIMIT); + if (!isCurrent()) return; + setRounds((previous) => keepIfSame(previous, { debateId: forDebate, items, failed: false })); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load sparring rounds:', cause instanceof Error ? cause.message : cause); + setRounds({ debateId: forDebate, items: null, failed: true }); + } + }, [userId, debateId]); // Reload on focus, so a round just judged or deleted shows up. useFocusEffect( useCallback(() => { - if (!userId) return; - let active = true; - fetchSparringRounds(userId, debateId, ROUNDS_LIMIT) - .then((items) => { - if (!active) return; - setRounds((previous) => keepIfSame(previous, { debateId, items })); - setFailed(false); - }) - .catch((cause: unknown) => { - console.warn('Could not load sparring rounds:', cause instanceof Error ? cause.message : cause); - if (active) setFailed(true); - }); + void load(); return () => { - active = false; + loads.current += 1; }; - }, [userId, debateId]), + }, [load]), ); - // Rounds read for another debate are not shown while this one's load. const items = rounds?.debateId === debateId ? rounds.items : null; + const failed = rounds?.debateId === debateId && rounds.failed; return ( diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index f5ef9c9e9..cccc234de 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -33,7 +33,7 @@ import { RoundNotesSheet, SparringScorecardScreen } from '@/components/sparring- import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; -import { AIError, isAbortError } from '@/lib/ai-request'; +import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { useDebates } from '@/lib/debate-context'; @@ -144,8 +144,8 @@ export default function SparringScreen() { requestRef.current = controller; setRequest({ status: 'pending' }); try { - const config = await fetchFeatureConfig(forUser, 'sparring'); - if (controller.signal.aborted) return; + // Read under the request's abort, so leaving while the settings load sends nothing. + const config = await untilAborted(fetchFeatureConfig(forUser, 'sparring'), controller.signal); const scorecard = await judgeSparring( { action: 'score', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage }, { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, @@ -178,8 +178,8 @@ export default function SparringScreen() { requestRef.current = controller; setRequest({ status: 'pending' }); try { - const config = await fetchFeatureConfig(forUser, 'sparring'); - if (controller.signal.aborted) return; + // Read under the request's abort, so leaving while the settings load sends nothing. + const config = await untilAborted(fetchFeatureConfig(forUser, 'sparring'), controller.signal); const { speaker: _speaker, ...currentTurn } = turn; const generated = await sparringTurn( { action: 'respond', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage, currentTurn }, diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index 21855dfea..5f7cef0dc 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -5,13 +5,13 @@ import type { TFunction } from 'i18next'; import { LinearGradient } from 'expo-linear-gradient'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, ScrollView, StyleSheet, TextInput, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Modal, StyleSheet, TextInput, View } from 'react-native'; import { PrimaryButton, SecondaryButton, TextButton } from './buttons'; import { Bullets, DocSection, DocumentScreen, Paragraph } from './document'; import { DocumentHeader } from './document-header'; import { SegmentedControl } from './segmented-control'; +import { SheetScrollView } from './sheet'; import { Text } from './text'; import { useAuth } from '@/lib/auth'; @@ -277,7 +277,7 @@ export function SparringTranscript({ speeches }: { speeches: SparringSpeech[] }) /** * The flow button's sheet: notes the student keeps during the round, and the - * transcript so far. The scroll view is the sheet's root, as every sheet's is. + * transcript so far. SheetScrollView is the sheet's root, as every sheet's is. */ export function RoundNotesSheet({ visible, @@ -294,17 +294,10 @@ export function RoundNotesSheet({ }) { const { t } = useTranslation(); const { colors } = useTheme(); - const insets = useSafeAreaInsets(); const [view, setView] = useState<'notes' | 'transcript'>('notes'); return ( - + {t('mobile.sparring.notes')} @@ -335,7 +328,7 @@ export function RoundNotesSheet({ )} - + ); } @@ -374,7 +367,7 @@ const styles = StyleSheet.create({ speechHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, speaker: { fontSize: 15, flexShrink: 1 }, used: { fontSize: 13.5, fontVariant: ['tabular-nums'] }, - sheet: { paddingHorizontal: 20, paddingTop: 18, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }, + sheet: { paddingTop: 18, gap: 16 }, sheetHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, sheetTitle: { fontSize: 22 }, notesInput: { minHeight: 260, borderWidth: 1, borderRadius: radius.field, padding: 14, fontSize: 16.5, lineHeight: 23, fontFamily: fontFamily.regular, textAlignVertical: 'top' }, From 1689eef3052c7dbca009cea72bf349cb4ea9a18e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:02:34 +0000 Subject: [PATCH 7/8] Open the Flowchart from the live round's flow button Now that the Flowchart is in the app, the live round's flow button opens it on the Current Debate's map, as mock 05 asks, instead of the session-only notes sheet that stood in for it. The round keeps running underneath. The notes sheet, its session slot and its three strings go. The screenshot run opens the Flowchart from the student's turn and checks the draft is still there on the way back. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 14 +++- apps/mobile/src/app/sparring/index.tsx | 16 ++--- apps/mobile/src/components/sparring-views.tsx | 69 +------------------ apps/mobile/src/lib/sparring.ts | 3 - src/locales/en.json | 3 - src/locales/es.json | 3 - src/locales/hu.json | 3 - 7 files changed, 18 insertions(+), 93 deletions(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index b4218bebe..fe86734ea 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -47,7 +47,8 @@ * Sparring screenshots never call a model and never save: the page answers * each opponent turn and the judge with fixed text in the run's language * (holding them open for the "opponent" and "judging" shots), and answers - * the attach_to_debate call locally. The run checks the turn-start taps. + * the attach_to_debate call locally. The run checks the turn-start taps. The + * flow button's Flowchart reads the fixed maps described below. * * The Timer runs on Playwright's clock, so its minutes pass at once, and the * run checks that it signalled one beep at the last minute and two at time up. @@ -912,6 +913,7 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan watchErrors(page, problems, `${prefix} sparring`); await pinSettings(page, language); const fake = await fakeSparring(page, language === 'hu' ? 'hu' : 'en'); + await fakeFlowcharts(page); await page.goto(`${ORIGIN}/practice`); await page.getByTestId('practice-tool-sparring').click({ timeout: 30_000 }); await page.getByTestId('sparring-setup').waitFor({ timeout: 15_000 }); @@ -939,6 +941,14 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan await shoot(page, `${prefix}-sparring-your-turn`); // The student's clock runs from the moment the turn starts. await page.getByTestId('sparring-time').filter({ hasText: /^6:5\d$/ }).waitFor({ timeout: 5_000 }); + // The flow button opens the Flowchart above the round, and the round is still there on the way back. + await page.getByTestId('sparring-flow').click(); + await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-sparring-flowchart`); + await page.goBack(); + await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); + if ((await page.getByTestId('sparring-input').inputValue()) !== speech) problems.push(`${prefix} sparring: the speech draft was lost after the Flowchart`); } if (index === text.speeches.length - 1) fake.hold('score'); await page.getByTestId('sparring-main').click(); @@ -1192,7 +1202,7 @@ async function main() { await shootCrossfireScreens(signedIn, prefix, language, problems); shots += 3; await shootSparringScreens(signedIn, prefix, language, problems); - shots += 5; + shots += 6; await shootFlowchart(signedIn, prefix, language, problems); shots += 10; const oneOffPage = await signedIn.newPage(); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index cccc234de..47c9cd5af 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -29,7 +29,7 @@ import { FormGroup, SelectRow, SwitchRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; -import { RoundNotesSheet, SparringScorecardScreen } from '@/components/sparring-views'; +import { SparringScorecardScreen } from '@/components/sparring-views'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; @@ -41,7 +41,7 @@ import { fetchFeatureConfig } from '@/lib/feature-config'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { useSettings } from '@/lib/settings'; -import { attachSparring, judgeSparring, newSparringId, SparringResponseError, sparringDraft, sparringNotes, sparringTurn } from '@/lib/sparring'; +import { attachSparring, judgeSparring, newSparringId, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; import { formatClock } from '@/lib/timer'; import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; import { radius } from '@/theme/colors'; @@ -473,8 +473,6 @@ function LiveRound({ const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); const { colors } = useTheme(); - const [notesOpen, setNotesOpen] = useState(false); - const [notes, setNotes] = useState(() => sparringNotes.get()[session.id] ?? ''); const [keyboard, setKeyboard] = useState(false); const [expanded, setExpanded] = useState(false); useKeepAwakeWhile(true); @@ -488,11 +486,6 @@ function LiveRound({ }; }, []); - const saveNotes = (value: string) => { - setNotes(value); - sparringNotes.set({ ...sparringNotes.get(), [session.id]: value }); - }; - const judging = session.phase === 'judging'; const turn = currentSparringTurn(session); const userTurn = !judging && turn?.speaker === 'user'; @@ -529,7 +522,8 @@ function LiveRound({ } - right={ setNotesOpen(true)} testID="sparring-flow" />} + // The Flowchart opens above the round on the Current Debate's map; the round keeps running underneath. + right={ router.push({ pathname: '/flowchart', params: { from: 'sparring' } })} testID="sparring-flow" />} title={judging ? t('sparring.round.complete') : turnLabel} subtitle={judging ? pressure : t('mobile.sparring.turnOf', { current: session.currentTurnIndex + 1, total: session.turns.length, pressure })} /> @@ -661,8 +655,6 @@ function LiveRound({ )} - - setNotesOpen(false)} /> ); } diff --git a/apps/mobile/src/components/sparring-views.tsx b/apps/mobile/src/components/sparring-views.tsx index 5f7cef0dc..247f5c034 100644 --- a/apps/mobile/src/components/sparring-views.tsx +++ b/apps/mobile/src/components/sparring-views.tsx @@ -5,13 +5,11 @@ import type { TFunction } from 'i18next'; import { LinearGradient } from 'expo-linear-gradient'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, StyleSheet, TextInput, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { PrimaryButton, SecondaryButton, TextButton } from './buttons'; +import { PrimaryButton, SecondaryButton } from './buttons'; import { Bullets, DocSection, DocumentScreen, Paragraph } from './document'; import { DocumentHeader } from './document-header'; -import { SegmentedControl } from './segmented-control'; -import { SheetScrollView } from './sheet'; import { Text } from './text'; import { useAuth } from '@/lib/auth'; @@ -20,7 +18,6 @@ import { confirmDestructive } from '@/lib/confirm'; import { shareText } from '@/lib/export'; import { deleteSparringRound } from '@/lib/sparring'; import { mainActionGradient, radius } from '@/theme/colors'; -import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; const CATEGORY_MAX = 25; @@ -275,64 +272,6 @@ export function SparringTranscript({ speeches }: { speeches: SparringSpeech[] }) ); } -/** - * The flow button's sheet: notes the student keeps during the round, and the - * transcript so far. SheetScrollView is the sheet's root, as every sheet's is. - */ -export function RoundNotesSheet({ - visible, - notes, - onChangeNotes, - speeches, - onClose, -}: { - visible: boolean; - notes: string; - onChangeNotes: (value: string) => void; - speeches: SparringSpeech[]; - onClose: () => void; -}) { - const { t } = useTranslation(); - const { colors } = useTheme(); - const [view, setView] = useState<'notes' | 'transcript'>('notes'); - return ( - - - - - {t('mobile.sparring.notes')} - - - - - value={view} - onChange={setView} - options={[ - { value: 'notes', label: t('mobile.sparring.notes') }, - { value: 'transcript', label: t('sparring.round.transcript') }, - ]} - /> - {view === 'notes' ? ( - - ) : ( - - - - )} - - - ); -} - const styles = StyleSheet.create({ scorecard: { gap: 12 }, heading: { gap: 2, marginBottom: 2 }, @@ -367,8 +306,4 @@ const styles = StyleSheet.create({ speechHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, speaker: { fontSize: 15, flexShrink: 1 }, used: { fontSize: 13.5, fontVariant: ['tabular-nums'] }, - sheet: { paddingTop: 18, gap: 16 }, - sheetHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, - sheetTitle: { fontSize: 22 }, - notesInput: { minHeight: 260, borderWidth: 1, borderRadius: radius.field, padding: 14, fontSize: 16.5, lineHeight: 23, fontFamily: fontFamily.regular, textAlignVertical: 'top' }, }); diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index f471f08e1..fec9c74ab 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -134,6 +134,3 @@ export function newSparringId(): string { * signed-in account and is forgotten when that changes. */ export const sparringDraft = sessionSlot(() => null); - -/** Notes the student takes during a round, by round id, for the session. */ -export const sparringNotes = sessionSlot>(() => ({})); diff --git a/src/locales/en.json b/src/locales/en.json index 5f466b675..0446df86f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2544,9 +2544,6 @@ "turnOf": "Turn {{current}} of {{total}}, {{pressure}}", "yourNext": "Your {{turn}} is next", "interrupted": "The request stopped when you left the round. Try again to carry on.", - "notes": "Notes", - "notesPlaceholder": "Their claims, your answers, anything to come back to. Notes stay until you close the app or sign out.", - "done": "Done", "resume": "Resume", "replay": "Replay", "versus": "{{user}} vs {{opponent}}", diff --git a/src/locales/es.json b/src/locales/es.json index a2bc2cf74..122d92072 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2544,9 +2544,6 @@ "turnOf": "Turno {{current}} de {{total}}, {{pressure}}", "yourNext": "Después te toca: {{turn}}", "interrupted": "La solicitud se detuvo al salir de la ronda. Vuelve a intentarlo para continuar.", - "notes": "Notas", - "notesPlaceholder": "Sus argumentos, tus respuestas y lo que quieras retomar. Las notas se guardan hasta que cierres la app o la sesión.", - "done": "Listo", "resume": "Reanudar", "replay": "Repasar", "versus": "{{user}} vs. {{opponent}}", diff --git a/src/locales/hu.json b/src/locales/hu.json index 361a8d6fc..012f7814f 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2544,9 +2544,6 @@ "turnOf": "{{current}}. kör a(z) {{total}} közül, {{pressure}}", "yourNext": "Utána te jössz: {{turn}}", "interrupted": "A kérés leállt, amikor kiléptél a fordulóból. Próbáld újra a folytatáshoz.", - "notes": "Jegyzetek", - "notesPlaceholder": "Az ellenfél állításai, a válaszaid, bármi, amire visszatérnél. A jegyzetek az app bezárásáig vagy a kijelentkezésig maradnak.", - "done": "Kész", "resume": "Folytatás", "replay": "Visszanézés", "versus": "{{user}} – {{opponent}}", From f696946c4378a419bf9a1480c99e16c74b9a2535 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:43:26 +0000 Subject: [PATCH 8/8] Keep a saved round to its account and drop a stale round start A reopened scorecard now remembers whose round it holds, so after an account change it shows loading instead of the previous account's round while the new read runs. Each side choice is numbered, so a slow debate creation from an earlier choice never starts a round over a newer one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/sparring/[id].tsx | 13 ++++++++----- apps/mobile/src/app/sparring/index.tsx | 7 +++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/app/sparring/[id].tsx b/apps/mobile/src/app/sparring/[id].tsx index 6e0d4f1c3..de72bae29 100644 --- a/apps/mobile/src/app/sparring/[id].tsx +++ b/apps/mobile/src/app/sparring/[id].tsx @@ -30,23 +30,26 @@ export default function SavedSparringScreen() { const { id, from } = useLocalSearchParams<{ id: string; from?: string }>(); const { session } = useAuth(); const userId = session?.user.id ?? null; - const [state, setState] = useState(() => { + // The state remembers whose round it holds, so another account never sees it while its own read runs. + const [held, setHeld] = useState<{ owner: string | null; state: State }>(() => { const kept = judged(sparringDraft.get(), id); - return kept ? { status: 'ready', round: kept } : { status: 'loading' }; + return { owner: userId, state: kept ? { status: 'ready', round: kept } : { status: 'loading' } }; }); + const state: State = held.owner === userId ? held.state : { status: 'loading' }; useEffect(() => { if (judged(sparringDraft.get(), id) || !userId) return; + const forUser = userId; let active = true; - fetchSparringRound(userId, id) + fetchSparringRound(forUser, id) .then((round) => { if (!active) return; const ready = judged(round, id); - setState(ready ? { status: 'ready', round: ready } : { status: 'missing' }); + setHeld({ owner: forUser, state: ready ? { status: 'ready', round: ready } : { status: 'missing' } }); }) .catch((cause: unknown) => { console.warn('Could not load the round:', cause instanceof Error ? cause.message : cause); - if (active) setState({ status: 'failed' }); + if (active) setHeld({ owner: forUser, state: { status: 'failed' } }); }); return () => { active = false; diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 47c9cd5af..455139cca 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -105,6 +105,8 @@ export default function SparringScreen() { const requestRef = useRef(null); const live = useRef(true); const owner = useRef(userId); + // Each side choice gets a number; a slower earlier one never starts a round over a newer one. + const startAttempt = useRef(0); // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { @@ -255,6 +257,7 @@ export default function SparringScreen() { const chooseSide = async (side: SparringSide) => { if (!userId) return; const forUser = userId; + const attempt = ++startAttempt.current; const text = motion.trim(); const decision = decideMotionTarget(text, { debates, current, oneOff }); if (decision.kind !== 'ask') { @@ -269,7 +272,7 @@ export default function SparringScreen() { onceLabel: current ? t('debate.useOnce') : t('debate.offerNotNow'), cancel: t('common.cancel'), }); - if (!choice || !live.current || owner.current !== forUser) return; + if (!choice || !live.current || owner.current !== forUser || startAttempt.current !== attempt) return; let debateId: string | null = null; try { if (choice.kind === 'switch') { @@ -283,7 +286,7 @@ export default function SparringScreen() { if (live.current) setFormError(t('debate.saveFailed')); return; } - if (live.current && owner.current === forUser) begin(side, debateId, forUser); + if (live.current && owner.current === forUser && startAttempt.current === attempt) begin(side, debateId, forUser); }; const submit = () => {