DebateLab
@@ -63,6 +65,7 @@ export function AppLayout() {
key={item.path}
to={item.path}
end={item.path === '/app'}
+ aria-label={t(`nav.${item.key}`)}
data-tour={`nav-${item.key}`}
className={cn(
"motion-press nav-glow flex items-center gap-1.5 px-2 xl:px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap",
diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts
index 540605527..15f940c4b 100644
--- a/src/lib/ai/client.ts
+++ b/src/lib/ai/client.ts
@@ -1,10 +1,27 @@
-import { mockGenerateCase, mockAttackCase, mockBrainstorm, mockFactoids, mockAsk } from './providers/mock-provider';
+import {
+ mockGenerateCase,
+ mockAttackCase,
+ mockBrainstorm,
+ mockFactoids,
+ mockAsk,
+ mockGenerateSparringTurn,
+ mockJudgeSparringSession,
+} from './providers/mock-provider';
import type { ProviderConfig } from './providers/types';
import { GenerateOutputSchema, type GenerateInput, type GenerateOutput } from './schemas/generate';
import { AttackOutputSchema, type AttackOutput, type AttackInput } from './schemas/attack';
import type { BrainstormOutput, BrainstormInput } from './schemas/brainstorm';
import { FactoidsOutputSchema, type FactoidsOutput, type FactoidsInput } from './schemas/factoids';
import { AskOutputSchema, type AskOutput } from './schemas/ask';
+import {
+ SparringInputSchema,
+ SparringScoreResponseSchema,
+ SparringTurnResponseSchema,
+ type SparringRespondInput,
+ type SparringScoreInput,
+ type SparringScoreResponse,
+ type SparringTurnResponse,
+} from './schemas/sparring';
import { useSettingsStore } from '../stores/settings-store';
import { useSessionStore } from '../stores/session-store';
import type { SourceProvenance } from '../stores/session-store';
@@ -35,6 +52,15 @@ interface AIRequestOptions {
signal?: AbortSignal;
}
+function waitForAbortable
(operation: () => PromiseLike, signal: AbortSignal): Promise {
+ if (signal.aborted) return Promise.reject(new DOMException('The request was canceled.', 'AbortError'));
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(new DOMException('The request was canceled.', 'AbortError'));
+ signal.addEventListener('abort', onAbort, { once: true });
+ Promise.resolve().then(operation).then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
+ });
+}
+
interface AIRequestSource {
title: string;
content: string;
@@ -261,8 +287,8 @@ export function isAIModelRestrictionError(error: unknown): error is AIModelRestr
}
async function callSupabaseAI(
- feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'crossfire',
- input: Record,
+ feature: 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring' | 'crossfire',
+ input: unknown,
config: ProviderConfig,
options: AIRequestOptions = {},
) {
@@ -272,7 +298,7 @@ async function callSupabaseAI(
const settings = useSettingsStore.getState();
const session = useSessionStore.getState();
- const selectedSources = options.includeActiveSources === false
+ const selectedSources = options.includeActiveSources === false || feature === 'sparring'
? []
: session.sourceLibrary
.filter((s) => s.isActive)
@@ -282,35 +308,6 @@ async function callSupabaseAI(
: selectedSources;
const provider = config.provider === 'lovable' ? 'openai' : config.provider;
- const { data: sessionData } = await supabase.auth.getSession();
- const accessToken = sessionData.session?.access_token;
-
- // Log request details in development
- if (DEBUG) {
- console.log(`[AI Request ${requestId}]`, {
- feature,
- provider,
- model: config.model,
- hasAccessToken: !!accessToken,
- activeSources: activeSources.length,
- });
- }
-
- // Require authentication for AI features
- if (!accessToken) {
- throw new AIAuthError('Please sign in to use AI features', { requestId });
- }
-
- const requestBody = {
- feature,
- input,
- provider,
- model: config.model,
- temperature: config.temperature,
- customSystemPrompt: options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null,
- sources: activeSources,
- };
-
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
@@ -320,6 +317,33 @@ async function callSupabaseAI(
else options.signal?.addEventListener('abort', abortFromCaller, { once: true });
try {
+ const { data: sessionData } = await waitForAbortable(() => supabase.auth.getSession(), controller.signal);
+ const accessToken = sessionData.session?.access_token;
+
+ if (DEBUG) {
+ console.log(`[AI Request ${requestId}]`, {
+ feature,
+ provider,
+ model: config.model,
+ hasAccessToken: !!accessToken,
+ activeSources: activeSources.length,
+ });
+ }
+
+ if (!accessToken) {
+ throw new AIAuthError('Please sign in to use AI features', { requestId });
+ }
+
+ const requestBody = {
+ feature,
+ input,
+ provider,
+ model: config.model,
+ temperature: config.temperature,
+ customSystemPrompt: options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null,
+ sources: activeSources,
+ };
+
const { data, error } = await supabase.functions.invoke('ai', {
headers: { Authorization: `Bearer ${accessToken}` },
body: requestBody,
@@ -1058,3 +1082,144 @@ export async function askChat(
}
return parsed.data;
}
+
+function sparringLanguage(language: string): string {
+ return language.trim() || useSettingsStore.getState().outputLanguage;
+}
+
+function validateSparringTurnResponse(output: unknown): SparringTurnResponse {
+ const parsed = SparringTurnResponseSchema.safeParse(output);
+ if (!parsed.success) {
+ throw new AIResponseError('AI response did not match the expected sparring turn schema.', {
+ details: { issues: parsed.error.issues },
+ });
+ }
+ return parsed.data;
+}
+
+function validateSparringScoreResponse(output: unknown): SparringScoreResponse {
+ const parsed = SparringScoreResponseSchema.safeParse(output);
+ if (!parsed.success) {
+ throw new AIResponseError('AI response did not match the expected sparring score schema.', {
+ details: { issues: parsed.error.issues },
+ });
+ }
+ return parsed.data;
+}
+
+// Page navigation can unmount and remount the sparring screen while a provider
+// request is still running. Keep identical work process-wide so the returning
+// screen joins the original promise instead of consuming a second AI request.
+const pendingSparringTurns = new Map>();
+const pendingSparringScores = new Map>();
+
+async function sparringRequestKey(
+ input: SparringRespondInput | SparringScoreInput,
+ config: ProviderConfig,
+): Promise {
+ const { data } = await supabase.auth.getSession();
+ const authScope = data.session?.user?.id ?? data.session?.access_token ?? 'signed-out';
+ return JSON.stringify([authScope, input, config.provider, config.model, config.temperature]);
+}
+
+/** Generate the opponent's next speech for a validated sparring turn. */
+export async function generateSparringTurn(
+ input: SparringRespondInput,
+ config: ProviderConfig,
+): Promise {
+ const requestInput: SparringRespondInput = {
+ ...input,
+ language: sparringLanguage(input.language),
+ };
+ const inputResult = SparringInputSchema.safeParse(requestInput);
+ if (!inputResult.success || inputResult.data.action !== 'respond') {
+ throw new AIResponseError('Invalid sparring turn request.', {
+ details: { issues: inputResult.success ? [{ message: 'Expected respond action.' }] : inputResult.error.issues },
+ });
+ }
+ const requestKey = await sparringRequestKey(inputResult.data, config);
+ const pending = pendingSparringTurns.get(requestKey);
+ if (pending) return pending;
+
+ const request = generateSparringTurnOnce(inputResult.data, config);
+ pendingSparringTurns.set(requestKey, request);
+ try {
+ return await request;
+ } finally {
+ if (pendingSparringTurns.get(requestKey) === request) pendingSparringTurns.delete(requestKey);
+ }
+}
+
+async function generateSparringTurnOnce(
+ input: SparringRespondInput,
+ config: ProviderConfig,
+): Promise {
+ const requestInput: SparringRespondInput = {
+ ...input,
+ language: sparringLanguage(input.language),
+ };
+ const inputResult = SparringInputSchema.safeParse(requestInput);
+ if (!inputResult.success || inputResult.data.action !== 'respond') {
+ throw new AIResponseError('Invalid sparring turn request.', {
+ details: { issues: inputResult.success ? [{ message: 'Expected respond action.' }] : inputResult.error.issues },
+ });
+ }
+
+ if (config.provider === 'mock') {
+ return validateSparringTurnResponse(await mockGenerateSparringTurn(inputResult.data));
+ }
+
+ const data = await callSupabaseAI('sparring', inputResult.data, config) as { output: unknown };
+ return validateSparringTurnResponse(data.output);
+}
+
+/** Judge a completed sparring session and return its validated scorecard. */
+export async function judgeSparringSession(
+ input: SparringScoreInput,
+ config: ProviderConfig,
+): Promise {
+ const requestInput: SparringScoreInput = {
+ ...input,
+ language: sparringLanguage(input.language),
+ };
+ const inputResult = SparringInputSchema.safeParse(requestInput);
+ if (!inputResult.success || inputResult.data.action !== 'score') {
+ throw new AIResponseError('Invalid sparring score request.', {
+ details: { issues: inputResult.success ? [{ message: 'Expected score action.' }] : inputResult.error.issues },
+ });
+ }
+ const requestKey = await sparringRequestKey(inputResult.data, config);
+ const pending = pendingSparringScores.get(requestKey);
+ if (pending) return pending;
+
+ const request = judgeSparringSessionOnce(inputResult.data, config);
+ pendingSparringScores.set(requestKey, request);
+ try {
+ return await request;
+ } finally {
+ if (pendingSparringScores.get(requestKey) === request) pendingSparringScores.delete(requestKey);
+ }
+}
+
+async function judgeSparringSessionOnce(
+ input: SparringScoreInput,
+ config: ProviderConfig,
+): Promise {
+ const requestInput: SparringScoreInput = {
+ ...input,
+ language: sparringLanguage(input.language),
+ };
+ const inputResult = SparringInputSchema.safeParse(requestInput);
+ if (!inputResult.success || inputResult.data.action !== 'score') {
+ throw new AIResponseError('Invalid sparring score request.', {
+ details: { issues: inputResult.success ? [{ message: 'Expected score action.' }] : inputResult.error.issues },
+ });
+ }
+
+ if (config.provider === 'mock') {
+ return validateSparringScoreResponse(await mockJudgeSparringSession(inputResult.data));
+ }
+
+ const data = await callSupabaseAI('sparring', inputResult.data, config) as { output: unknown };
+ return validateSparringScoreResponse(data.output);
+}
diff --git a/src/lib/ai/providers/mock-provider.ts b/src/lib/ai/providers/mock-provider.ts
index 4bd47692c..ff9565a3a 100644
--- a/src/lib/ai/providers/mock-provider.ts
+++ b/src/lib/ai/providers/mock-provider.ts
@@ -4,6 +4,12 @@ import type { AttackOutput } from '../schemas/attack';
import type { BrainstormOutput } from '../schemas/brainstorm';
import type { FactoidsOutput } from '../schemas/factoids';
import type { AskOutput } from '../schemas/ask';
+import type {
+ SparringRespondInput,
+ SparringScoreInput,
+ SparringScoreResponse,
+ SparringTurnResponse,
+} from '../schemas/sparring';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -393,3 +399,114 @@ export async function mockAsk(message: string, mode: string): Promise
return baseResponse;
}
+
+function wordCount(text: string): number {
+ return text.trim() ? text.trim().split(/\s+/).length : 0;
+}
+
+function clampScore(score: number): number {
+ return Math.max(0, Math.min(25, Math.round(score)));
+}
+
+/** Deterministic sparring reply for local development and offline UI tests. */
+export async function mockGenerateSparringTurn(input: SparringRespondInput): Promise {
+ const latestSpeech = [...input.speeches].reverse().find((speech) => speech.speaker === 'user');
+ const latestPoint = latestSpeech?.content.trim().replace(/\s+/g, ' ').slice(0, 140);
+ const difficultyLine = {
+ easy: 'I will keep this response focused on the most visible clash.',
+ medium: 'I will answer the central claim and press the warrant underneath it.',
+ hard: 'I will test the warrant, the link, and the impact comparison before moving on.',
+ }[input.difficulty];
+ const targetedPoint = latestPoint
+ ? `Your latest claim, “${latestPoint}”, needs a clearer link to the motion.`
+ : `The ${input.currentTurn.label.toLowerCase()} needs a clear claim, warrant, and impact.`;
+ const roleMove = {
+ introductor: 'Set the framework, thesis, definitions, and roadmap without spending the full constructive case.',
+ r1: 'Build the core constructive case, then answer the strongest opposing material already available.',
+ r2: 'Extend R1, win the central clash, and compare the competing impacts.',
+ conclusor: 'Add no new argument; crystallize the decisive clashes, weigh them, and close the ballot.',
+ opening: 'Establish the opening case and its central mechanism.',
+ constructive: 'Build the constructive case with a complete warrant and impact.',
+ rebuttal: 'Answer the strongest opposing material and protect the core case.',
+ summary: 'Collapse the round to its central clashes and comparisons.',
+ final: 'Weigh the remaining offense and explain the ballot without adding a new argument.',
+ }[input.currentTurn.kind];
+
+ return {
+ action: 'respond',
+ speech: {
+ content: `As ${input.currentTurn.label}, the job is to ${roleMove.charAt(0).toLowerCase()}${roleMove.slice(1)} On ${input.motion}, the ${input.currentTurn.side} side has to explain why its proposed outcome follows in practice. ${targetedPoint} ${difficultyLine}`,
+ strategy: roleMove,
+ targetedPoints: [targetedPoint],
+ },
+ };
+}
+
+/** Deterministic scorecard for local development. It never depends on time or randomness. */
+export async function mockJudgeSparringSession(input: SparringScoreInput): Promise {
+ const userWords = input.speeches
+ .filter((speech) => speech.speaker === 'user')
+ .reduce((total, speech) => total + wordCount(speech.content), 0);
+ const aiWords = input.speeches
+ .filter((speech) => speech.speaker === 'ai')
+ .reduce((total, speech) => total + wordCount(speech.content), 0);
+ const userTurns = input.speeches.filter((speech) => speech.speaker === 'user').length;
+ const aiTurns = input.speeches.filter((speech) => speech.speaker === 'ai').length;
+ const difficultyAdjustment = { easy: -2, medium: 0, hard: 2 }[input.difficulty];
+ const userDepth = Math.min(10, userWords / 80);
+ const aiDepth = Math.min(10, aiWords / 80);
+ const userScores = {
+ Logic: clampScore(14 + userDepth),
+ Evidence: clampScore(11 + userDepth / 2),
+ Delivery: clampScore(13 + Math.min(10, userTurns * 2)),
+ Strategy: clampScore(12 + userDepth / 2 + (userTurns > 1 ? 2 : 0)),
+ };
+ const aiScores = {
+ Logic: clampScore(14 + aiDepth + difficultyAdjustment),
+ Evidence: clampScore(12 + aiDepth / 2 + difficultyAdjustment),
+ Delivery: clampScore(14 + Math.min(10, aiTurns * 2) + difficultyAdjustment),
+ Strategy: clampScore(13 + aiDepth / 2 + (aiTurns > 1 ? 2 : 0) + difficultyAdjustment),
+ };
+ const categoryNames = ['Logic', 'Evidence', 'Delivery', 'Strategy'] as const;
+ const userScore = categoryNames.reduce((total, name) => total + userScores[name], 0);
+ const aiScore = categoryNames.reduce((total, name) => total + aiScores[name], 0);
+ const winner = userScore === aiScore ? 'tie' : userScore > aiScore ? 'user' : 'ai';
+ const winnerLabel = winner === 'tie' ? 'The round is close.' : `${winner === 'user' ? 'You' : 'The AI'} win the simulated ballot.`;
+ const firstUserSpeech = input.speeches.find((speech) => speech.speaker === 'user');
+
+ return {
+ action: 'score',
+ scorecard: {
+ userScore,
+ aiScore,
+ categories: categoryNames.map((name) => ({
+ name,
+ userScore: userScores[name],
+ aiScore: aiScores[name],
+ feedback: name === 'Delivery'
+ ? input.format === 'standard'
+ ? 'Judged from clarity, organization, time discipline, and fulfillment of the Introductor, R1, R2, and Conclusor roles, not vocal performance.'
+ : 'Judged from clarity, organization, and time discipline in the transcript, not vocal performance.'
+ : `The comparison reflects the ${name.toLowerCase()} visible in the completed speeches.`,
+ })),
+ overallFeedback: `${winnerLabel} The mock judge compared the completed speeches on the motion “${input.motion}”.`,
+ keyMoments: firstUserSpeech
+ ? [`Your opening turn established: “${firstUserSpeech.content.trim().replace(/\s+/g, ' ').slice(0, 120)}”`]
+ : ['No user speech was submitted, so there was no user argument to weigh.'],
+ improvementAreas: [
+ 'Make each warrant explicit before moving to the impact.',
+ input.format === 'standard'
+ ? 'Keep each school-format speaker in role: Introductor frames, R1 builds, R2 clashes, and Conclusor adds no new argument.'
+ : 'Name the opponent’s strongest point, then explain why your weighing still wins.',
+ ],
+ winner,
+ verdict: winner === 'tie'
+ ? 'Close round. The next improvement should target the clearest unresolved clash.'
+ : `${winner === 'user' ? 'Your' : 'The AI’s'} side wins on the aggregate category score.`,
+ },
+ };
+}
+
+// Names kept short for callers that treat the mock provider as a feature adapter.
+export const mockSparringResponse = mockGenerateSparringTurn;
+export const mockSparringScore = mockJudgeSparringSession;
diff --git a/src/lib/ai/schemas/sparring.ts b/src/lib/ai/schemas/sparring.ts
new file mode 100644
index 000000000..a8e6db363
--- /dev/null
+++ b/src/lib/ai/schemas/sparring.ts
@@ -0,0 +1,151 @@
+import { z } from 'zod';
+
+export const SparringSideSchema = z.enum(['pro', 'con']);
+export const SparringFormatSchema = z.enum(['standard', 'ld', 'pf']);
+export const SparringDifficultySchema = z.enum(['easy', 'medium', 'hard']);
+export const SparringSpeakerSchema = z.enum(['user', 'ai']);
+export const SparringTurnKindSchema = z.enum([
+ 'introductor',
+ 'r1',
+ 'r2',
+ 'conclusor',
+ 'opening',
+ 'constructive',
+ 'rebuttal',
+ 'summary',
+ 'final',
+]);
+
+export const SparringTurnSchema = z.object({
+ id: z.string().trim().min(1),
+ label: z.string().trim().min(1),
+ side: SparringSideSchema,
+ kind: SparringTurnKindSchema,
+ timeLimitSeconds: z.number().int().positive(),
+});
+
+/** A completed turn from either debater, sent as context for the next call. */
+export const SparringSpeechSchema = z.object({
+ id: z.string().trim().min(1),
+ turnId: z.string().trim().min(1),
+ speaker: SparringSpeakerSchema,
+ side: SparringSideSchema,
+ role: z.string().trim().min(1).max(120),
+ content: z.string().trim().min(1).max(6_000),
+ createdAt: z.number().finite(),
+ timeLimitSeconds: z.number().int().positive(),
+ timeUsedSeconds: z.number().int().nonnegative(),
+ integrityToken: z.string().trim().min(32).max(128).optional(),
+});
+
+/** Shared, bounded session context carried by both sparring actions. */
+export const SparringSessionSchema = z.object({
+ sessionId: z.string().uuid(),
+ motion: z.string().trim().min(1).max(1_200),
+ userSide: SparringSideSchema,
+ format: SparringFormatSchema,
+ difficulty: SparringDifficultySchema,
+ speeches: z.array(SparringSpeechSchema).max(12),
+ language: z.string().trim().min(1).max(32),
+});
+
+export const SparringRespondInputSchema = SparringSessionSchema.extend({
+ action: z.literal('respond'),
+ currentTurn: SparringTurnSchema,
+});
+
+export const SparringScoreInputSchema = SparringSessionSchema.extend({
+ action: z.literal('score'),
+});
+
+export const SparringInputSchema = z.discriminatedUnion('action', [
+ SparringRespondInputSchema,
+ SparringScoreInputSchema,
+]);
+
+export const SparringTurnResponseSchema = z.object({
+ action: z.literal('respond'),
+ speech: z.object({
+ content: z.string().trim().min(1).max(6_000),
+ strategy: z.string().trim().min(1).max(2_000),
+ targetedPoints: z.array(z.string().trim().min(1).max(500)).max(8),
+ integrityToken: z.string().trim().min(32).max(128).optional(),
+ }),
+});
+
+export const SparringScoreCategoryNameSchema = z.enum([
+ 'Logic',
+ 'Evidence',
+ 'Delivery',
+ 'Strategy',
+]);
+
+export const SparringScoreCategorySchema = z.object({
+ name: SparringScoreCategoryNameSchema,
+ userScore: z.number().int().min(0).max(25),
+ aiScore: z.number().int().min(0).max(25),
+ feedback: z.string().trim().min(1).max(2_000),
+});
+
+const REQUIRED_SCORE_CATEGORIES = new Set(['Logic', 'Evidence', 'Delivery', 'Strategy']);
+
+export const SparringScorecardSchema = z.object({
+ userScore: z.number().int().min(0).max(100),
+ aiScore: z.number().int().min(0).max(100),
+ categories: z.array(SparringScoreCategorySchema).length(4).superRefine((categories, ctx) => {
+ const names: Set = new Set(categories.map((category) => category.name));
+ if (names.size !== REQUIRED_SCORE_CATEGORIES.size || ['Logic', 'Evidence', 'Delivery', 'Strategy'].some((name) => !names.has(name))) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Categories must include exactly Logic, Evidence, Delivery, and Strategy.',
+ });
+ }
+ }),
+ overallFeedback: z.string().trim().min(1).max(4_000),
+ keyMoments: z.array(z.string().trim().min(1).max(1_000)).max(12),
+ improvementAreas: z.array(z.string().trim().min(1).max(1_000)).max(12),
+ winner: z.enum(['user', 'ai', 'tie']),
+ verdict: z.string().trim().min(1).max(2_000),
+}).superRefine((scorecard, ctx) => {
+ const categoryUserScore = scorecard.categories.reduce((total, category) => total + category.userScore, 0);
+ const categoryAiScore = scorecard.categories.reduce((total, category) => total + category.aiScore, 0);
+ if (scorecard.userScore !== categoryUserScore) {
+ ctx.addIssue({ code: 'custom', path: ['userScore'], message: 'userScore must equal the category score total.' });
+ }
+ if (scorecard.aiScore !== categoryAiScore) {
+ ctx.addIssue({ code: 'custom', path: ['aiScore'], message: 'aiScore must equal the category score total.' });
+ }
+ const expectedWinner = scorecard.userScore === scorecard.aiScore
+ ? 'tie'
+ : scorecard.userScore > scorecard.aiScore ? 'user' : 'ai';
+ if (scorecard.winner !== expectedWinner) {
+ ctx.addIssue({ code: 'custom', path: ['winner'], message: 'winner must match the aggregate scores.' });
+ }
+});
+
+export const SparringScoreResponseSchema = z.object({
+ action: z.literal('score'),
+ scorecard: SparringScorecardSchema,
+});
+
+export const SparringResponseSchema = z.discriminatedUnion('action', [
+ SparringTurnResponseSchema,
+ SparringScoreResponseSchema,
+]);
+
+export type SparringSide = z.infer;
+export type SparringFormat = z.infer;
+export type SparringDifficulty = z.infer;
+export type SparringSpeaker = z.infer;
+export type SparringTurnKind = z.infer;
+export type SparringTurn = z.infer;
+export type SparringSpeech = z.infer;
+export type SparringSession = z.infer;
+export type SparringRespondInput = z.infer;
+export type SparringScoreInput = z.infer;
+export type SparringInput = z.infer;
+export type SparringTurnResponse = z.infer;
+export type SparringScoreCategory = z.infer;
+export type SparringScorecard = z.infer;
+export type SparringScoreResponse = z.infer;
+export type SparringResponse = z.infer;
diff --git a/src/lib/export-utils.ts b/src/lib/export-utils.ts
index b9c2ac93d..5f6673912 100644
--- a/src/lib/export-utils.ts
+++ b/src/lib/export-utils.ts
@@ -3,7 +3,38 @@ import type { GenerateOutput } from './ai/schemas/generate';
import type { AttackOutput } from './ai/schemas/attack';
import type { BrainstormOutput } from './ai/schemas/brainstorm';
import type { FactoidsOutput } from './ai/schemas/factoids';
-import { formatEuropeanDate } from './date-format';
+import type { SparringSession } from './stores/sparring-store';
+import { formatEuropeanDate, formatEuropeanDateTime } from './date-format';
+
+function formatSparringDuration(seconds: number): string {
+ const totalSeconds = Math.max(0, Math.round(seconds));
+ const minutes = Math.floor(totalSeconds / 60);
+ const remainder = String(totalSeconds % 60).padStart(2, '0');
+ return `${minutes}:${remainder}`;
+}
+
+function getSparringSideLabel(side: SparringSession['userSide']): string {
+ return side === 'pro' ? i18n.t('export.proposition') : i18n.t('export.opposition');
+}
+
+function getSparringWinnerLabel(session: SparringSession): string {
+ const winner = session.scorecard?.winner;
+ if (winner === 'user') return i18n.t('export.sparringUser');
+ if (winner === 'ai') return i18n.t('export.sparringOpponent');
+ return i18n.t('export.sparringTie');
+}
+
+function getSparringCategoryLabel(category: string): string {
+ return i18n.t(`sparring.category.${category}`, category);
+}
+
+function getSparringTurnLabel(speech: SparringSession['speeches'][number]): string {
+ return i18n.t(`sparring.turns.${speech.turnId}`, speech.role);
+}
+
+function getSparringFormatLabel(format: SparringSession['format']): string {
+ return i18n.t(`sparring.format.${format}`, format.toUpperCase());
+}
export async function exportCaseToDocx(result: GenerateOutput): Promise {
try {
@@ -558,3 +589,115 @@ export async function exportFactoidsToGoogleDocs(result: FactoidsOutput): Promis
}
return callGoogleDocsEdgeFunction('factoids', result);
}
+
+// ============================================================
+// SPARRING TRANSCRIPT DOCX EXPORT
+// ============================================================
+export async function exportSparringToDocx(session: SparringSession): Promise {
+ try {
+ const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } = await import('docx');
+ const { saveAs } = await import('file-saver');
+ const totalTimeUsedSeconds = session.speeches.reduce((total, speech) => total + speech.timeUsedSeconds, 0);
+ const totalTimeLimitSeconds = session.speeches.reduce((total, speech) => total + speech.timeLimitSeconds, 0);
+ const scorecard = session.scorecard;
+ const metadata = [
+ [i18n.t('export.motion'), session.motion],
+ [i18n.t('export.sparringFormat'), getSparringFormatLabel(session.format)],
+ [i18n.t('export.side'), getSparringSideLabel(session.userSide)],
+ [i18n.t('export.sparringDifficulty'), i18n.t(`sparring.difficulty.${session.difficulty}`, session.difficulty)],
+ [i18n.t('export.sparringCompleted'), formatEuropeanDateTime(session.updatedAt)],
+ [i18n.t('export.sparringTimeUsed'), `${formatSparringDuration(totalTimeUsedSeconds)} / ${formatSparringDuration(totalTimeLimitSeconds)}`],
+ ].map(([label, value]) => new Paragraph({
+ children: [new TextRun({ text: `${label}: `, bold: true }), new TextRun(value)],
+ spacing: { after: 100 },
+ }));
+
+ const scorecardContent = scorecard
+ ? [
+ new Paragraph({ text: i18n.t('export.sparringTotals').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 300, after: 150 } }),
+ new Paragraph({
+ children: [
+ new TextRun({ text: `${i18n.t('export.sparringUser')}: `, bold: true }),
+ new TextRun(`${scorecard.userScore}/100`),
+ new TextRun({ text: ` ${i18n.t('export.sparringOpponent')}: `, bold: true }),
+ new TextRun(`${scorecard.aiScore}/100`),
+ ],
+ spacing: { after: 100 },
+ }),
+ new Paragraph({
+ children: [new TextRun({ text: `${i18n.t('export.sparringWinner')}: `, bold: true }), new TextRun(getSparringWinnerLabel(session))],
+ spacing: { after: 200 },
+ }),
+ new Paragraph({ text: i18n.t('export.sparringCategories').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 200, after: 150 } }),
+ ...scorecard.categories.flatMap((category) => [
+ new Paragraph({
+ children: [new TextRun({ text: `${getSparringCategoryLabel(category.name)}: `, bold: true }), new TextRun(`${category.userScore}/25 ${i18n.t('export.sparringUser').toLowerCase()} | ${category.aiScore}/25 ${i18n.t('export.sparringOpponent').toLowerCase()}`)],
+ spacing: { after: 75 },
+ }),
+ new Paragraph({ text: category.feedback, spacing: { after: 150 } }),
+ ]),
+ new Paragraph({ text: i18n.t('export.sparringFeedback').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 200, after: 150 } }),
+ new Paragraph({ text: scorecard.verdict, spacing: { after: 100 } }),
+ new Paragraph({ text: scorecard.overallFeedback, spacing: { after: 150 } }),
+ ...(scorecard.keyMoments.length > 0
+ ? [
+ new Paragraph({ text: i18n.t('export.sparringKeyMoments'), heading: HeadingLevel.HEADING_2, spacing: { before: 150, after: 75 } }),
+ ...scorecard.keyMoments.map((moment) => new Paragraph({ text: `• ${moment}`, spacing: { after: 75 } })),
+ ]
+ : []),
+ ...(scorecard.improvementAreas.length > 0
+ ? [
+ new Paragraph({ text: i18n.t('export.sparringImprovementAreas'), heading: HeadingLevel.HEADING_2, spacing: { before: 150, after: 75 } }),
+ ...scorecard.improvementAreas.map((area) => new Paragraph({ text: `• ${area}`, spacing: { after: 75 } })),
+ ]
+ : []),
+ ]
+ : [
+ new Paragraph({ text: i18n.t('export.sparringScorecardUnavailable'), spacing: { before: 300, after: 150 } }),
+ ];
+
+ const transcriptContent = session.speeches.length > 0
+ ? session.speeches.flatMap((speech, index) => [
+ new Paragraph({
+ children: [new TextRun({ text: `${index + 1}. ${getSparringTurnLabel(speech)}`, bold: true })],
+ spacing: { before: 200, after: 75 },
+ }),
+ new Paragraph({
+ children: [
+ new TextRun({ text: `${i18n.t('export.side')}: `, bold: true }),
+ new TextRun(getSparringSideLabel(speech.side)),
+ new TextRun({ text: ` ${i18n.t('export.sparringTimeUsed')}: `, bold: true }),
+ new TextRun(`${formatSparringDuration(speech.timeUsedSeconds)} / ${formatSparringDuration(speech.timeLimitSeconds)}`),
+ ],
+ spacing: { after: 75 },
+ }),
+ new Paragraph({ text: speech.content, spacing: { after: 150 } }),
+ ])
+ : [new Paragraph({ text: i18n.t('export.sparringNoTranscript'), spacing: { after: 150 } })];
+
+ const doc = new Document({
+ sections: [{
+ properties: {},
+ children: [
+ new Paragraph({
+ text: i18n.t('export.sparringTranscript'),
+ heading: HeadingLevel.HEADING_1,
+ alignment: AlignmentType.CENTER,
+ spacing: { after: 250 },
+ }),
+ ...metadata,
+ ...scorecardContent,
+ new Paragraph({ text: i18n.t('export.sparringTranscript').toUpperCase(), heading: HeadingLevel.HEADING_2, spacing: { before: 300, after: 150 } }),
+ ...transcriptContent,
+ ],
+ }],
+ });
+
+ const blob = await Packer.toBlob(doc);
+ saveAs(blob, `SparringTranscript_${new Date(session.updatedAt).toISOString().slice(0, 10)}.docx`);
+ return true;
+ } catch (error) {
+ console.error('Failed to export sparring transcript to DOCX:', error);
+ return false;
+ }
+}
diff --git a/src/lib/pdf-export-utils.ts b/src/lib/pdf-export-utils.ts
index 314c71632..f6f7dad4e 100644
--- a/src/lib/pdf-export-utils.ts
+++ b/src/lib/pdf-export-utils.ts
@@ -5,6 +5,7 @@ import type { GenerateOutput } from './ai/schemas/generate';
import type { AttackOutput } from './ai/schemas/attack';
import type { BrainstormOutput } from './ai/schemas/brainstorm';
import type { FactoidsOutput } from './ai/schemas/factoids';
+import type { SparringSession } from './stores/sparring-store';
import { formatEuropeanDate } from './date-format';
// Color constants for consistent branding
@@ -18,6 +19,36 @@ const COLORS = {
light: [248, 250, 252] as [number, number, number], // Light
};
+function formatSparringDuration(seconds: number): string {
+ const totalSeconds = Math.max(0, Math.round(seconds));
+ const minutes = Math.floor(totalSeconds / 60);
+ const remainder = String(totalSeconds % 60).padStart(2, '0');
+ return `${minutes}:${remainder}`;
+}
+
+function getSparringSideLabel(side: SparringSession['userSide']): string {
+ return side === 'pro' ? i18n.t('export.proposition') : i18n.t('export.opposition');
+}
+
+function getSparringWinnerLabel(session: SparringSession): string {
+ const winner = session.scorecard?.winner;
+ if (winner === 'user') return i18n.t('export.sparringUser');
+ if (winner === 'ai') return i18n.t('export.sparringOpponent');
+ return i18n.t('export.sparringTie');
+}
+
+function getSparringCategoryLabel(category: string): string {
+ return i18n.t(`sparring.category.${category}`, category);
+}
+
+function getSparringTurnLabel(speech: SparringSession['speeches'][number]): string {
+ return i18n.t(`sparring.turns.${speech.turnId}`, speech.role);
+}
+
+function getSparringFormatLabel(format: SparringSession['format']): string {
+ return i18n.t(`sparring.format.${format}`, format.toUpperCase());
+}
+
interface PDFHelpers {
doc: import('jspdf').jsPDF;
y: number;
@@ -199,6 +230,31 @@ async function createPDFDocument(): Promise {
return { doc, get y() { return state.y; }, set y(v: number) { state.y = v; }, pageHeight, margin, contentWidth, checkPageBreak, addHeader, addSectionTitle, addParagraph, addBulletList, addTable, addFooter };
}
+function addSparringTranscriptParagraph(pdf: PDFHelpers, text: string) {
+ const { doc } = pdf;
+ const lineHeight = 4.8;
+ const lines = doc.splitTextToSize(text, pdf.contentWidth);
+ let remaining = lines;
+
+ while (remaining.length > 0) {
+ const availableHeight = pdf.pageHeight - pdf.margin - 15 - pdf.y;
+ const linesOnPage = Math.max(1, Math.floor(availableHeight / lineHeight));
+ const pageLines = remaining.slice(0, linesOnPage);
+ remaining = remaining.slice(linesOnPage);
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(...COLORS.dark);
+ doc.text(pageLines, pdf.margin, pdf.y);
+ pdf.y += pageLines.length * lineHeight;
+
+ if (remaining.length > 0) {
+ pdf.checkPageBreak(pdf.pageHeight);
+ }
+ }
+
+ pdf.y += 4;
+}
+
// ============================================================
// GENERATE CASE EXPORT
// ============================================================
@@ -590,3 +646,100 @@ export async function exportFactoidsToPdf(result: FactoidsOutput): Promise {
+ try {
+ const pdf = await createPDFDocument();
+ const { doc, addHeader, addSectionTitle, addParagraph, addBulletList, addTable, addFooter } = pdf;
+ const totalTimeUsedSeconds = session.speeches.reduce((total, speech) => total + speech.timeUsedSeconds, 0);
+ const totalTimeLimitSeconds = session.speeches.reduce((total, speech) => total + speech.timeLimitSeconds, 0);
+ const scorecard = session.scorecard;
+
+ addHeader(
+ i18n.t('export.sparringTranscript'),
+ `${getSparringSideLabel(session.userSide)} | ${getSparringFormatLabel(session.format)} | ${i18n.t(`sparring.difficulty.${session.difficulty}`, session.difficulty)}`,
+ );
+
+ addSectionTitle(i18n.t('export.sparringConfiguration'));
+ addParagraph(`${i18n.t('export.motion')}: ${session.motion}`);
+ addParagraph(`${i18n.t('export.sparringFormat')}: ${getSparringFormatLabel(session.format)}`);
+ addParagraph(`${i18n.t('export.side')}: ${getSparringSideLabel(session.userSide)}`);
+ addParagraph(`${i18n.t('export.sparringDifficulty')}: ${i18n.t(`sparring.difficulty.${session.difficulty}`, session.difficulty)}`);
+
+ addSectionTitle(i18n.t('export.sparringTotals'), COLORS.success);
+ const scoreRows = scorecard
+ ? [[
+ i18n.t('export.sparringUser'),
+ `${scorecard.userScore}/100`,
+ i18n.t('export.sparringOpponent'),
+ `${scorecard.aiScore}/100`,
+ `${i18n.t('export.sparringWinner')}: ${getSparringWinnerLabel(session)}`,
+ ]]
+ : [[i18n.t('export.sparringScorecardUnavailable'), '', '', '', '']];
+ addTable(
+ [
+ i18n.t('export.sparringUser'),
+ i18n.t('export.sparringScore'),
+ i18n.t('export.sparringOpponent'),
+ i18n.t('export.sparringScore'),
+ i18n.t('export.sparringResult'),
+ ],
+ scoreRows,
+ { headerColor: COLORS.success },
+ );
+ addParagraph(`${i18n.t('export.sparringSpeeches')}: ${session.speeches.length}`);
+ addParagraph(`${i18n.t('export.sparringTimeUsed')}: ${formatSparringDuration(totalTimeUsedSeconds)} / ${formatSparringDuration(totalTimeLimitSeconds)}`);
+
+ if (scorecard) {
+ addSectionTitle(i18n.t('export.sparringCategories'), COLORS.primary);
+ addTable(
+ [
+ i18n.t('export.sparringCategory'),
+ i18n.t('export.sparringUser'),
+ i18n.t('export.sparringOpponent'),
+ i18n.t('export.sparringFeedback'),
+ ],
+ scorecard.categories.map((category) => [
+ getSparringCategoryLabel(category.name),
+ `${category.userScore}/25`,
+ `${category.aiScore}/25`,
+ category.feedback,
+ ]),
+ );
+
+ addSectionTitle(i18n.t('export.sparringFeedback'), COLORS.warning);
+ addParagraph(scorecard.verdict);
+ addParagraph(scorecard.overallFeedback);
+ if (scorecard.keyMoments.length > 0) {
+ addSectionTitle(i18n.t('export.sparringKeyMoments'), COLORS.success);
+ addBulletList(scorecard.keyMoments);
+ }
+ if (scorecard.improvementAreas.length > 0) {
+ addSectionTitle(i18n.t('export.sparringImprovementAreas'), COLORS.warning);
+ addBulletList(scorecard.improvementAreas);
+ }
+ }
+
+ addSectionTitle(i18n.t('export.sparringTranscript'), COLORS.primary);
+ if (session.speeches.length === 0) {
+ addParagraph(i18n.t('export.sparringNoTranscript'));
+ } else {
+ session.speeches.forEach((speech, index) => {
+ addSectionTitle(`${index + 1}. ${getSparringTurnLabel(speech)}`, COLORS.secondary);
+ addParagraph(`${i18n.t('export.side')}: ${getSparringSideLabel(speech.side)}`);
+ addParagraph(`${i18n.t('export.sparringTimeUsed')}: ${formatSparringDuration(speech.timeUsedSeconds)} / ${formatSparringDuration(speech.timeLimitSeconds)}`);
+ addSparringTranscriptParagraph(pdf, speech.content);
+ });
+ }
+
+ addFooter();
+ doc.save(`SparringTranscript_${new Date(session.updatedAt).toISOString().slice(0, 10)}.pdf`);
+ return true;
+ } catch (error) {
+ console.error('Failed to export sparring transcript to PDF:', error);
+ return false;
+ }
+}
diff --git a/src/lib/sparring/formats.ts b/src/lib/sparring/formats.ts
new file mode 100644
index 000000000..d2a1d0395
--- /dev/null
+++ b/src/lib/sparring/formats.ts
@@ -0,0 +1,96 @@
+import type { SparringFormat, SparringSide } from '@/lib/ai/schemas/sparring';
+
+export type SparringTurnKind = 'introductor' | 'r1' | 'r2' | 'conclusor' | 'opening' | 'constructive' | 'rebuttal' | 'summary' | 'final';
+
+export interface SparringTurn {
+ id: string;
+ label: string;
+ side: SparringSide;
+ speaker: 'user' | 'ai';
+ kind: SparringTurnKind;
+ timeLimitSeconds: number;
+}
+
+export interface SparringFormatDefinition {
+ id: SparringFormat;
+ name: string;
+ shortDescription: string;
+ turnCount: number;
+ approximateMinutes: number;
+}
+
+const FORMAT_DEFINITIONS: Record = {
+ standard: {
+ id: 'standard',
+ name: 'School 4-6-6-3',
+ shortDescription: 'Your school format: Introductor, R1, R2, and Conclusor for both sides.',
+ turnCount: 8,
+ approximateMinutes: 38,
+ },
+ ld: {
+ id: 'ld',
+ name: 'Lincoln-Douglas',
+ shortDescription: 'A focused values round with constructives and two rebuttal stages.',
+ turnCount: 5,
+ approximateMinutes: 26,
+ },
+ pf: {
+ id: 'pf',
+ name: 'Public Forum',
+ shortDescription: 'Constructive, rebuttal, summary, and final focus for both sides.',
+ turnCount: 8,
+ approximateMinutes: 26,
+ },
+};
+
+type TurnTemplate = Omit;
+
+const TURN_TEMPLATES: Record = {
+ standard: [
+ { label: 'Pro Introductor', side: 'pro', kind: 'introductor', timeLimitSeconds: 4 * 60 },
+ { label: 'Con Introductor', side: 'con', kind: 'introductor', timeLimitSeconds: 4 * 60 },
+ { label: 'Pro R1', side: 'pro', kind: 'r1', timeLimitSeconds: 6 * 60 },
+ { label: 'Con R1', side: 'con', kind: 'r1', timeLimitSeconds: 6 * 60 },
+ { label: 'Pro R2', side: 'pro', kind: 'r2', timeLimitSeconds: 6 * 60 },
+ { label: 'Con R2', side: 'con', kind: 'r2', timeLimitSeconds: 6 * 60 },
+ { label: 'Pro Conclusor', side: 'pro', kind: 'conclusor', timeLimitSeconds: 3 * 60 },
+ { label: 'Con Conclusor', side: 'con', kind: 'conclusor', timeLimitSeconds: 3 * 60 },
+ ],
+ ld: [
+ { label: 'Affirmative constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 6 * 60 },
+ { label: 'Negative constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 7 * 60 },
+ { label: 'First affirmative rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 },
+ { label: 'Negative rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 6 * 60 },
+ { label: 'Second affirmative rebuttal', side: 'pro', kind: 'final', timeLimitSeconds: 3 * 60 },
+ ],
+ pf: [
+ { label: 'Pro constructive', side: 'pro', kind: 'constructive', timeLimitSeconds: 4 * 60 },
+ { label: 'Con constructive', side: 'con', kind: 'constructive', timeLimitSeconds: 4 * 60 },
+ { label: 'Pro rebuttal', side: 'pro', kind: 'rebuttal', timeLimitSeconds: 4 * 60 },
+ { label: 'Con rebuttal', side: 'con', kind: 'rebuttal', timeLimitSeconds: 4 * 60 },
+ { label: 'Pro summary', side: 'pro', kind: 'summary', timeLimitSeconds: 3 * 60 },
+ { label: 'Con summary', side: 'con', kind: 'summary', timeLimitSeconds: 3 * 60 },
+ { label: 'Pro final focus', side: 'pro', kind: 'final', timeLimitSeconds: 2 * 60 },
+ { label: 'Con final focus', side: 'con', kind: 'final', timeLimitSeconds: 2 * 60 },
+ ],
+};
+
+export const SPARRING_FORMATS = Object.values(FORMAT_DEFINITIONS);
+
+export function getSparringFormat(format: SparringFormat): SparringFormatDefinition {
+ return FORMAT_DEFINITIONS[format];
+}
+
+export function buildSparringTurns(format: SparringFormat, userSide: SparringSide): SparringTurn[] {
+ return TURN_TEMPLATES[format].map((turn, index) => ({
+ ...turn,
+ id: `${format}-${index + 1}`,
+ speaker: turn.side === userSide ? 'user' : 'ai',
+ }));
+}
+
+export function formatRoundTime(seconds: number): string {
+ const safeSeconds = Math.max(0, Math.floor(seconds));
+ const minutes = Math.floor(safeSeconds / 60);
+ return `${minutes}:${String(safeSeconds % 60).padStart(2, '0')}`;
+}
diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts
index e80581b83..099e9d994 100644
--- a/src/lib/stores/settings-store.ts
+++ b/src/lib/stores/settings-store.ts
@@ -4,14 +4,16 @@ import i18n from '@/i18n';
import type { ProviderConfig } from '../ai/providers/types';
import { supabase } from '@/lib/supabase/client';
-export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask';
+export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring';
+type CoreFeatureTab = Exclude;
+type FeatureConfigs = Record & Partial>;
interface SettingsState {
language: 'en' | 'es' | 'hu';
outputLanguage: 'en' | 'es' | 'hu';
theme: 'light' | 'dark' | 'system';
customSystemPrompt?: string;
- featureConfigs: Record;
+ featureConfigs: FeatureConfigs;
wikiResearchEnabled: boolean;
setLanguage: (lang: 'en' | 'es' | 'hu') => void;
@@ -37,6 +39,7 @@ const getDefaultFeatureConfigs = (): Record => ({
brainstorm: { ...defaultConfig, temperature: 0.9 },
factoids: { ...defaultConfig, temperature: 0.3 },
ask: { ...defaultConfig },
+ sparring: { ...defaultConfig },
});
function normalizeFeatureConfigs(configs: unknown): Record {
diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts
new file mode 100644
index 000000000..47e4850d2
--- /dev/null
+++ b/src/lib/stores/sparring-store.ts
@@ -0,0 +1,461 @@
+import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
+import { z } from 'zod';
+import { supabase } from '@/lib/supabase/client';
+import {
+ SparringScorecardSchema,
+ 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';
+
+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;
+ createdAt: number;
+ updatedAt: number;
+}
+
+interface StartSessionInput {
+ motion: string;
+ userSide: SparringSide;
+ format: SparringFormat;
+ difficulty: SparringDifficulty;
+}
+
+interface SparringStoreState {
+ ownerUserId: string | null;
+ currentSession: SparringSession | null;
+ savedSessions: SparringSession[];
+ hydrated: boolean;
+ startSession: (input: StartSessionInput) => SparringSession;
+ setDraft: (draft: string) => void;
+ tickTimer: (now?: number) => void;
+ pauseTimer: (now?: number) => void;
+ resumeTimer: (now?: number) => void;
+ resetTurnTimer: (now?: number) => void;
+ submitUserSpeech: (content: string, now?: number) => SparringSession | null;
+ appendAiSpeech: (speech: SparringSpeech, now?: number) => SparringSession | null;
+ completeSession: (scorecard: SparringScorecard, now?: number) => SparringSession | null;
+ hydrateSavedSessions: () => Promise;
+ prepareForUser: (userId: string) => void;
+ loadSavedSession: (id: string) => void;
+ deleteSavedSession: (id: string) => Promise;
+ abandonSession: () => void;
+ resetLocalData: () => void;
+}
+
+// Invalidates any remote hydration that was started for a previous auth state.
+// 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(),
+ created_at: z.string(),
+ updated_at: z.string(),
+}));
+
+const PersistedSparringSessionSchema = z.object({
+ id: z.string(),
+ motion: z.string(),
+ userSide: z.enum(['pro', 'con']),
+ format: z.enum(['standard', 'ld', 'pf']),
+ difficulty: z.enum(['easy', 'medium', 'hard']),
+ turns: z.array(SparringTurnSchema.extend({ speaker: SparringSpeakerSchema })),
+ speeches: z.array(SparringSpeechSchema),
+ currentTurnIndex: z.number().int().nonnegative(),
+ phase: z.enum(['round', 'judging', 'complete']),
+ draft: z.string(),
+ timerRemainingSeconds: z.number().nonnegative(),
+ timerRunning: z.boolean(),
+ timerDeadline: z.number().nullable(),
+ scorecard: SparringScorecardSchema.optional(),
+ createdAt: z.number(),
+ updatedAt: z.number(),
+});
+
+const PersistedSparringStateSchema = z.object({
+ ownerUserId: z.string().nullable().optional(),
+ currentSession: PersistedSparringSessionSchema.nullable().optional(),
+ savedSessions: z.array(PersistedSparringSessionSchema).optional(),
+});
+
+type PersistedSparringState = Pick;
+const MAX_SAVED_SESSIONS = 50;
+const PERSIST_DELAY_MS = 250;
+let pendingStorageWrite: { name: string; value: string } | null = null;
+let storageWriteTimer: ReturnType | null = null;
+
+function flushPendingStorageWrite() {
+ if (!pendingStorageWrite || typeof sessionStorage === 'undefined') return;
+ sessionStorage.setItem(pendingStorageWrite.name, pendingStorageWrite.value);
+ pendingStorageWrite = null;
+ if (storageWriteTimer) clearTimeout(storageWriteTimer);
+ storageWriteTimer = null;
+}
+
+function withoutVolatileDraftFields(value: string): string | null {
+ try {
+ const parsed = JSON.parse(value) as { state?: { currentSession?: { draft?: string; updatedAt?: number } | null } };
+ if (parsed.state?.currentSession) {
+ parsed.state.currentSession.draft = '';
+ parsed.state.currentSession.updatedAt = 0;
+ }
+ return JSON.stringify(parsed);
+ } catch {
+ return null;
+ }
+}
+
+const debouncedSessionStorage = {
+ getItem: (name: string) => sessionStorage.getItem(name),
+ setItem: (name: string, value: string) => {
+ const persistedValue = sessionStorage.getItem(name);
+ const draftOnlyChange = persistedValue !== null
+ && withoutVolatileDraftFields(persistedValue) === withoutVolatileDraftFields(value);
+ if (!draftOnlyChange) {
+ if (storageWriteTimer) clearTimeout(storageWriteTimer);
+ storageWriteTimer = null;
+ pendingStorageWrite = null;
+ sessionStorage.setItem(name, value);
+ return;
+ }
+ pendingStorageWrite = { name, value };
+ if (storageWriteTimer) clearTimeout(storageWriteTimer);
+ storageWriteTimer = setTimeout(flushPendingStorageWrite, PERSIST_DELAY_MS);
+ },
+ removeItem: (name: string) => {
+ if (pendingStorageWrite?.name === name) pendingStorageWrite = null;
+ if (storageWriteTimer) clearTimeout(storageWriteTimer);
+ storageWriteTimer = null;
+ sessionStorage.removeItem(name);
+ },
+};
+
+if (typeof window !== 'undefined') window.addEventListener('pagehide', flushPendingStorageWrite);
+
+function migratePersistedSparringState(value: unknown): PersistedSparringState {
+ const parsed = PersistedSparringStateSchema.safeParse(value);
+ if (!parsed.success) return { ownerUserId: null, currentSession: null, savedSessions: [] };
+ const rebuildTurns = (session: z.output): SparringSession => ({
+ ...session,
+ timerDeadline: session.timerDeadline ?? null,
+ turns: buildSparringTurns(session.format, session.userSide),
+ });
+ return {
+ ownerUserId: parsed.data.ownerUserId ?? null,
+ currentSession: parsed.data.currentSession ? rebuildTurns(parsed.data.currentSession) : null,
+ savedSessions: (parsed.data.savedSessions ?? []).map(rebuildTurns),
+ };
+}
+
+function createId(): string {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ const segment = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
+ 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 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,
+ createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now(),
+ }];
+ });
+}
+
+export const useSparringStore = create()(
+ persist(
+ (set, get) => ({
+ ownerUserId: null as string | null,
+ currentSession: null as SparringSession | null,
+ savedSessions: [] as SparringSession[],
+ hydrated: false,
+
+ 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),
+ createdAt: now,
+ updatedAt: now,
+ };
+ set({ currentSession: session });
+ return session;
+ },
+
+ setDraft: (draft) => set((state) => state.currentSession
+ ? { 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) } };
+ }),
+
+ submitUserSpeech: (content, now = Date.now()) => {
+ 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),
+ };
+ const next = advanceSession({ ...session, speeches: [...session.speeches, speech] }, now);
+ set({ currentSession: next });
+ return next;
+ },
+
+ 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);
+ set({ currentSession: next });
+ return next;
+ },
+
+ 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,
+ };
+ set((state) => ({
+ currentSession: completed,
+ savedSessions: mergeSessions(state.savedSessions, [completed]),
+ }));
+ return completed;
+ },
+
+ prepareForUser: (userId) => {
+ if (get().ownerUserId === userId) return;
+ hydrationEpoch += 1;
+ set({ ownerUserId: userId, currentSession: null, savedSessions: [], hydrated: false });
+ },
+
+ hydrateSavedSessions: async () => {
+ if (get().hydrated) return;
+ const epoch = hydrationEpoch;
+ const { data: { user } } = await supabase.auth.getUser();
+ if (epoch !== hydrationEpoch) return;
+ if (!user) {
+ set({ hydrated: true });
+ return;
+ }
+ const { data, error } = await supabase
+ .from('sparring_sessions')
+ .select('id,motion,user_side,format,difficulty,turns,final_evaluation,status,created_at,updated_at')
+ .order('updated_at', { ascending: false });
+ if (epoch !== hydrationEpoch) return;
+ if (error) {
+ console.warn('[SparringStore] Failed to hydrate sparring history:', error.message);
+ set({ hydrated: true });
+ return;
+ }
+ set((state) => ({ savedSessions: mergeSessions(state.savedSessions, parseSavedRows(data)), hydrated: true }));
+ },
+
+ loadSavedSession: (id) => {
+ const session = get().savedSessions.find((item) => item.id === id);
+ if (session) set({ currentSession: { ...session } });
+ },
+
+ deleteSavedSession: async (id) => {
+ const previous = get().savedSessions;
+ set({ savedSessions: previous.filter((session) => session.id !== id) });
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return;
+ const { error } = await supabase.from('sparring_sessions').delete().eq('id', id);
+ if (error) {
+ set({ savedSessions: previous });
+ throw new Error(error.message);
+ }
+ },
+
+ abandonSession: () => set({ currentSession: null }),
+ resetLocalData: () => {
+ hydrationEpoch += 1;
+ set({ ownerUserId: null, currentSession: null, savedSessions: [], hydrated: false });
+ },
+ }),
+ {
+ name: 'debate-sparring-storage',
+ storage: createJSONStorage(() => debouncedSessionStorage),
+ version: 3,
+ migrate: (persistedState) => migratePersistedSparringState(persistedState),
+ merge: (persistedState, currentState) => ({
+ ...currentState,
+ ...migratePersistedSparringState(persistedState),
+ }),
+ partialize: (state) => ({
+ ownerUserId: state.ownerUserId,
+ currentSession: state.currentSession,
+ savedSessions: state.savedSessions.slice(0, MAX_SAVED_SESSIONS),
+ }),
+ },
+ ),
+);
diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx
index c6b682193..2213ea9cc 100644
--- a/src/lib/supabase/AuthProvider.tsx
+++ b/src/lib/supabase/AuthProvider.tsx
@@ -4,6 +4,7 @@ import type { Session, User } from '@supabase/supabase-js';
import { supabase } from './client';
import { useSessionStore } from '@/lib/stores/session-store';
import { useSettingsStore } from '@/lib/stores/settings-store';
+import { useSparringStore } from '@/lib/stores/sparring-store';
import { getOAuthRedirectUrl, openExternalUrl } from '@/lib/native';
import { setJamUserId } from '@/lib/jam-metadata';
import { clearCrossfireDraftOnAuthChange } from '@/lib/crossfire/storage';
@@ -39,8 +40,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const hydrateSessionStore = useSessionStore((state) => state.hydrateFromServer);
const hydrateSettingsStore = useSettingsStore((state) => state.hydrateFromServer);
+ const hydrateSparringStore = useSparringStore((state) => state.hydrateSavedSessions);
+ const prepareSparringStore = useSparringStore((state) => state.prepareForUser);
const resetSessionStore = useSessionStore((state) => state.resetLocalData);
const resetSettingsStore = useSettingsStore((state) => state.resetLocalData);
+ const resetSparringStore = useSparringStore((state) => state.resetLocalData);
const currentUserIdRef = useRef(undefined);
useEffect(() => {
@@ -49,8 +53,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const hydrateStores = (userId?: string) => {
if (!userId || hydratedUserId === userId) return;
hydratedUserId = userId;
+ prepareSparringStore(userId);
Promise.resolve(hydrateSessionStore()).catch((err) => console.warn('[Auth] hydrateSessionStore failed:', err));
Promise.resolve(hydrateSettingsStore()).catch((err) => console.warn('[Auth] hydrateSettingsStore failed:', err));
+ Promise.resolve(hydrateSparringStore()).catch((err) => console.warn('[Auth] hydrateSparringStore failed:', err));
};
// Older clients persisted these credentials across browser sessions.
@@ -159,6 +165,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
hydratedUserId = undefined;
resetSessionStore();
resetSettingsStore();
+ resetSparringStore();
sessionStorage.removeItem('google_provider_token');
localStorage.removeItem('google_provider_token');
// Remove refresh tokens written by older clients as well; this client
@@ -171,6 +178,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
hydratedUserId = undefined;
resetSessionStore();
resetSettingsStore();
+ resetSparringStore();
}
hydrateStores(nextUserId);
@@ -180,7 +188,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
mounted = false;
authListener.subscription.unsubscribe();
};
- }, [hydrateSessionStore, hydrateSettingsStore, resetSessionStore, resetSettingsStore]);
+ }, [hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareSparringStore, resetSessionStore, resetSettingsStore, resetSparringStore]);
const value = useMemo(() => ({
session,
@@ -212,8 +220,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
clearCrossfireDraftOnAuthChange(session?.user?.id, undefined);
resetSessionStore();
resetSettingsStore();
+ resetSparringStore();
},
- }), [session, loading, resetSessionStore, resetSettingsStore]);
+ }), [session, loading, resetSessionStore, resetSettingsStore, resetSparringStore]);
return {children} ;
}
diff --git a/src/locales/en.json b/src/locales/en.json
index e5717ca20..db537337d 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -9,6 +9,7 @@
"crossfire": "Crossfire",
"library": "Library",
"timer": "Timer",
+ "sparring": "Sparring",
"flowchart": "Flowchart",
"history": "History",
"features": "Features",
@@ -30,6 +31,7 @@
"crossfire": "Practice live questioning",
"library": "Manage sources",
"timer": "Practice timing",
+ "sparring": "Practice a live round",
"flowchart": "Map arguments",
"history": "Saved items"
},
@@ -63,6 +65,8 @@
"libraryDesc": "Manage and toggle sources used for grounded responses.",
"timer": "Timer",
"timerDesc": "Practice speaking rounds with tournament timing controls.",
+ "sparring": "Sparring",
+ "sparringDesc": "Practice a timed debate round against an AI opponent and review the scorecard.",
"flowchart": "Flowchart",
"flowchartDesc": "Map arguments and rebuttal links in a visual debate tree.",
"history": "History",
@@ -157,6 +161,7 @@
"signedInAs": "Signed in as",
"unknownUser": "Unknown user",
"signOut": "Sign out",
+ "signOutFailed": "Could not sign out. Please try again.",
"aiModels": "AI Models",
"aiModelsDesc": "Select your preferred model directly from each feature page using the model dropdown in the header.",
"admin": "Admin",
@@ -902,6 +907,7 @@
"tabAttacks": "Attacks",
"tabBrainstorms": "Brainstorms",
"tabFactoids": "Factoids",
+ "tabSparring": "Sparring",
"casesTitle": "Saved Cases",
"casesDesc": "Your generated debate cases",
"chatsTitle": "Chat History",
@@ -927,6 +933,17 @@
"attackCase": "Attack a case",
"brainstormIdeas": "Brainstorm ideas",
"findEvidence": "Find evidence",
+ "sparringTitle": "Sparring rounds",
+ "sparringDesc": "Completed timed debates with scorecards and transcripts",
+ "emptySparring": "No sparring rounds yet",
+ "emptySparringDesc": "Complete a timed debate round to review it here.",
+ "startSparring": "Start sparring",
+ "score": "You {{user}} - AI {{ai}}",
+ "deleteSparringAria": "Delete sparring round: {{motion}}",
+ "deleteSparringTitle": "Delete sparring round?",
+ "deleteSparringDesc": "This removes the scorecard and transcript from your history.",
+ "sparringDeleted": "Sparring round deleted.",
+ "sparringDeleteFailed": "Could not delete sparring round.",
"load": "Load",
"resume": "Resume",
"messages": "messages"
@@ -1127,7 +1144,216 @@
"counters": "Prepared Counters",
"weaknesses": "Weaknesses Identified",
"fallacies": "Fallacies Detected",
- "crossExQuestions": "Cross-Examination Questions"
+ "crossExQuestions": "Cross-Examination Questions",
+ "motion": "Motion",
+ "sparringTranscript": "Sparring transcript",
+ "sparringConfiguration": "Round configuration",
+ "sparringFormat": "Format",
+ "sparringDifficulty": "Difficulty",
+ "sparringCompleted": "Completed",
+ "sparringTimeUsed": "Time used",
+ "sparringTotals": "Final totals",
+ "sparringUser": "You",
+ "sparringOpponent": "AI opponent",
+ "sparringWinner": "Winner",
+ "sparringTie": "Tie",
+ "sparringScore": "Score",
+ "sparringResult": "Result",
+ "sparringSpeeches": "Speeches",
+ "sparringCategories": "Category scores",
+ "sparringCategory": "Category",
+ "sparringFeedback": "Feedback",
+ "sparringKeyMoments": "Key moments",
+ "sparringImprovementAreas": "Work on next",
+ "sparringScorecardUnavailable": "A final scorecard is not available yet.",
+ "sparringNoTranscript": "No speeches were submitted for this round."
+ },
+ "sparring": {
+ "pro": "Pro",
+ "con": "Con",
+ "user": "You",
+ "opponent": "AI opponent",
+ "format": {
+ "standard": "School 4-6-6-3",
+ "ld": "Lincoln-Douglas",
+ "pf": "Public Forum"
+ },
+ "formatDescription": {
+ "standard": "Your school format: Introductor, R1, R2, and Conclusor for both sides.",
+ "ld": "A focused values round with constructives and two rebuttal stages.",
+ "pf": "Constructive, rebuttal, summary, and final focus for both sides."
+ },
+ "turns": {
+ "standard-1": "Pro Introductor",
+ "standard-2": "Con Introductor",
+ "standard-3": "Pro R1",
+ "standard-4": "Con R1",
+ "standard-5": "Pro R2",
+ "standard-6": "Con R2",
+ "standard-7": "Pro Conclusor",
+ "standard-8": "Con Conclusor",
+ "ld-1": "Affirmative constructive",
+ "ld-2": "Negative constructive",
+ "ld-3": "First affirmative rebuttal",
+ "ld-4": "Negative rebuttal",
+ "ld-5": "Second affirmative rebuttal",
+ "pf-1": "Pro constructive",
+ "pf-2": "Con constructive",
+ "pf-3": "Pro rebuttal",
+ "pf-4": "Con rebuttal",
+ "pf-5": "Pro summary",
+ "pf-6": "Con summary",
+ "pf-7": "Pro final focus",
+ "pf-8": "Con final focus"
+ },
+ "difficulty": {
+ "easy": "Warm-up",
+ "medium": "Tournament",
+ "hard": "Ruthless"
+ },
+ "roleGuidance": {
+ "introductor": "Open with an exordio, define the key terms, set the framework and thesis, then give the roadmap. Save the full arguments for R1.",
+ "r1": "Build the core arguments with claims, warrants, support, and impacts. Use the final part to anticipate or answer the strongest opposition.",
+ "r2": "Extend R1, answer the opponent directly, identify the central clashes, and compare the impacts. Do not simply repeat the case.",
+ "conclusor": "Add no new arguments. Crystallize the decisive clashes, weigh the round, and close with the reason your side wins."
+ },
+ "category": {
+ "Logic": "Logic",
+ "Evidence": "Evidence",
+ "Delivery": "Delivery",
+ "Strategy": "Strategy"
+ },
+ "setup": {
+ "liveLabel": "Live practice room",
+ "heading": "Spar with an opponent who listens.",
+ "subtitle": "Choose a motion, take a side, and work through a timed round. The AI keeps the transcript in view, answers the arguments you actually make, and scores the written debate at the end.",
+ "onlineReady": "Ready to call the debate coach",
+ "offlineDraft": "Offline. Your draft stays in this tab.",
+ "setMotion": "Set the motion",
+ "setMotionDescription": "Write the resolution as you would hear it in the room.",
+ "motion": "Motion",
+ "motionPlaceholder": "This house would require social media companies to verify the age of every user.",
+ "motionHelp": "A precise motion gives the opponent something concrete to attack.",
+ "motionTooShort": "Add a little more detail so the motion is judgeable.",
+ "pickSide": "Pick your side",
+ "pickSideDescription": "You will write every speech for the side you choose.",
+ "proDescription": "Defend the resolution and show why it should pass.",
+ "conDescription": "Challenge the resolution and show why it should fail.",
+ "choosePressure": "Choose the pressure",
+ "choosePressureDescription": "The opponent adapts its arguments to this level.",
+ "difficultyDescription": {
+ "easy": "Clear arguments and generous openings.",
+ "medium": "A capable opponent who follows the flow.",
+ "hard": "Fast counters that look for every gap."
+ },
+ "roundFormat": "Round format",
+ "roundFormatDescription": "Set the speaking order.",
+ "speeches": "{{count}} speeches",
+ "aboutMinutes": "About {{count}} minutes",
+ "roomAtGlance": "Your room, at a glance",
+ "speakingPlan": "Speaking plan",
+ "turns": "{{count}} turns",
+ "opponentTurns": "Opponent turns",
+ "aiRequests": "AI requests",
+ "aiRequestBreakdown": "{{count}} replies + 1 judge",
+ "aiModel": "AI model",
+ "modelHelp": "Model settings follow the sparring profile in Settings.",
+ "openingRoom": "Opening room...",
+ "startRound": "Start round",
+ "draftNotice": "Your draft is saved in this tab as you type. You can pause without losing your place.",
+ "offlineCannotStart": "Reconnect to start a round. Your setup is kept while you are offline."
+ },
+ "round": {
+ "inProgress": "Round in progress",
+ "aiRequestFailed": "The AI request failed. Check your connection and try again.",
+ "judgeRequestFailed": "The judge request failed. Check your connection and try again.",
+ "aiOffline": "Reconnect to let the opponent answer. Your transcript is safe.",
+ "judgeOffline": "Reconnect to score this round. Your transcript is safe.",
+ "backToWorkspace": "Back to workspace",
+ "draftSavedNotice": "Drafts save in this tab",
+ "connected": "Connected",
+ "offlineDraft": "Offline. Draft saved locally.",
+ "totalWords": "{{count}} words",
+ "complete": "Round complete",
+ "turnOf": "Turn {{current}} of {{total}}",
+ "preparingResult": "Preparing your result",
+ "judgeReading": "The judge is reading the transcript.",
+ "scoring": "Scoring the round",
+ "noTimer": "No timer while judging",
+ "timeRemaining": "{{time}} remaining",
+ "pauseClock": "Pause clock",
+ "resumeClock": "Resume clock",
+ "resetTurn": "Reset turn",
+ "clockNote": "The clock uses the saved deadline, not tab focus.",
+ "scoreFailed": "Could not score this round",
+ "retryScoring": "Try scoring again",
+ "judgeWeighing": "Judge is weighing the round",
+ "judgeDescription": "Reading the claims, responses, and strategic choices. This usually takes one request.",
+ "yourSpeech": "Your speech",
+ "yourSpeechDescription": "Make the case in the time you have. The opponent will respond to this text.",
+ "wordCount": "{{count}} words",
+ "speechPlaceholder": "Build your claim, warrant, and impact...",
+ "draftFor": "Draft for {{label}}",
+ "toSubmit": "to submit",
+ "submitSpeech": "Submit speech",
+ "timeExpired": "This speaking time has ended. Reset the turn to continue.",
+ "speechRejected": "This speech could not be submitted. Check the timer and length, then try again.",
+ "offlineCannotSubmit": "Reconnect to submit this speech.",
+ "opponentFailed": "The opponent could not answer",
+ "retryOpponent": "Retry opponent turn",
+ "offlineRetry": "Reconnect before retrying.",
+ "opponentThinking": "Opponent is thinking",
+ "opponentUpNext": "Opponent is up next",
+ "opponentThinkingDescription": "It is reading the motion and your speeches before choosing a response.",
+ "opponentUpNextDescription": "The next response will appear here.",
+ "aiThinking": "AI is thinking",
+ "transcript": "Transcript",
+ "transcriptDescription": "Every submitted speech stays here for the judge.",
+ "submittedCount": "{{count}} submitted",
+ "transcriptEmpty": "Your first speech will appear here after you submit it.",
+ "roundPlan": "Round plan",
+ "completeStatus": "Complete",
+ "timeUsed": "{{time}} used",
+ "estimatedTimeUsed": "{{time}} estimated",
+ "youArePro": "You are Pro",
+ "youAreCon": "You are Con"
+ },
+ "scorecard": {
+ "eyebrow": "Round review",
+ "heading": "Here is what happened in the room.",
+ "exportDocx": "Export DOCX",
+ "exportPdf": "PDF",
+ "restart": "Start another round",
+ "finalResult": "Final result",
+ "userWon": "You won the ballot",
+ "opponentWon": "AI opponent won the ballot",
+ "tie": "The round was a tie",
+ "tied": "Tied",
+ "winner": "Winner",
+ "keepTraining": "Keep training",
+ "yourScore": "Your score",
+ "opponentScore": "Opponent score",
+ "topScore": "Top score",
+ "verdict": "Judge's verdict",
+ "categoryBalance": "Category balance",
+ "categoryDescription": "Each category is scored out of 25.",
+ "radarLabel": "Radar chart comparing category scores",
+ "radarNote": "Visual summary only. Read the text scores below for the full feedback.",
+ "categoryNotes": "Category notes",
+ "categoryNotesDescription": "Specific reasons behind every score. Delivery is judged from the written speech, not audio.",
+ "writtenOnly": "written only",
+ "accessibleCategoryScore": "Your score {{user}} out of 25. Opponent score {{opponent}} out of 25.",
+ "aiShort": "AI",
+ "keyMoments": "Key moments",
+ "noKeyMoments": "No key moments were recorded.",
+ "workOnNext": "Work on next",
+ "noImprovementAreas": "No improvement areas were recorded.",
+ "overallFeedback": "Overall feedback",
+ "fullTranscript": "Full transcript",
+ "transcriptDescription": "{{count}} speeches from this {{format}} round.",
+ "hideTranscript": "Hide transcript",
+ "showTranscript": "Show transcript"
+ }
},
"langSwitcher": {
"label": "Language",
diff --git a/src/locales/es.json b/src/locales/es.json
index d73ba9db3..539102349 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -9,6 +9,7 @@
"crossfire": "Interrogatorio",
"library": "Biblioteca",
"timer": "Cronómetro",
+ "sparring": "Sparring",
"flowchart": "Diagrama",
"history": "Historial",
"features": "Funciones",
@@ -30,6 +31,7 @@
"crossfire": "Practicar preguntas en vivo",
"library": "Gestionar fuentes",
"timer": "Practicar tiempos",
+ "sparring": "Practicar una ronda en directo",
"flowchart": "Mapear argumentos",
"history": "Elementos guardados"
},
@@ -63,6 +65,8 @@
"libraryDesc": "Gestiona y activa fuentes usadas para respuestas fundamentadas.",
"timer": "Cronómetro",
"timerDesc": "Practica rondas de oratoria con controles de tiempo de torneo.",
+ "sparring": "Sparring",
+ "sparringDesc": "Practica una ronda de debate cronometrada contra una oponente de IA y revisa la tarjeta de puntuación.",
"flowchart": "Diagrama",
"flowchartDesc": "Mapea argumentos y enlaces de refutación en un árbol visual.",
"history": "Historial",
@@ -157,6 +161,7 @@
"signedInAs": "Sesión iniciada como",
"unknownUser": "Usuario desconocido",
"signOut": "Cerrar sesión",
+ "signOutFailed": "No se pudo cerrar la sesión. Inténtalo de nuevo.",
"aiModels": "Modelos de IA",
"aiModelsDesc": "Selecciona tu modelo preferido desde cada página de función usando el selector del encabezado.",
"admin": "Admin",
@@ -902,6 +907,7 @@
"tabAttacks": "Ataques",
"tabBrainstorms": "Lluvias de Ideas",
"tabFactoids": "Datos",
+ "tabSparring": "Sparring",
"casesTitle": "Casos Guardados",
"casesDesc": "Tus casos de debate generados",
"chatsTitle": "Historial de Chats",
@@ -927,6 +933,17 @@
"attackCase": "Atacar un caso",
"brainstormIdeas": "Explorar ideas",
"findEvidence": "Buscar evidencia",
+ "sparringTitle": "Rondas de sparring",
+ "sparringDesc": "Debates cronometrados terminados con tarjetas de puntuación y transcripciones",
+ "emptySparring": "Aún no hay rondas de sparring",
+ "emptySparringDesc": "Completa una ronda de debate cronometrada para revisarla aquí.",
+ "startSparring": "Empezar sparring",
+ "score": "Tú {{user}} - IA {{ai}}",
+ "deleteSparringAria": "Eliminar ronda de sparring: {{motion}}",
+ "deleteSparringTitle": "¿Eliminar ronda de sparring?",
+ "deleteSparringDesc": "Esto elimina la tarjeta de puntuación y la transcripción de tu historial.",
+ "sparringDeleted": "Ronda de sparring eliminada.",
+ "sparringDeleteFailed": "No se pudo eliminar la ronda de sparring.",
"load": "Cargar",
"resume": "Reanudar",
"messages": "mensajes"
@@ -1127,7 +1144,216 @@
"counters": "Contra-argumentos Preparados",
"weaknesses": "Debilidades Identificadas",
"fallacies": "Falacias Detectadas",
- "crossExQuestions": "Preguntas de Interrogatorio Cruzado"
+ "crossExQuestions": "Preguntas de Interrogatorio Cruzado",
+ "motion": "Moción",
+ "sparringTranscript": "Transcripción de sparring",
+ "sparringConfiguration": "Configuración de la ronda",
+ "sparringFormat": "Formato",
+ "sparringDifficulty": "Dificultad",
+ "sparringCompleted": "Completado",
+ "sparringTimeUsed": "Tiempo usado",
+ "sparringTotals": "Totales finales",
+ "sparringUser": "Tú",
+ "sparringOpponent": "Oponente de IA",
+ "sparringWinner": "Ganador",
+ "sparringTie": "Empate",
+ "sparringScore": "Puntuación",
+ "sparringResult": "Resultado",
+ "sparringSpeeches": "Intervenciones",
+ "sparringCategories": "Puntuaciones por categoría",
+ "sparringCategory": "Categoría",
+ "sparringFeedback": "Comentarios",
+ "sparringKeyMoments": "Momentos clave",
+ "sparringImprovementAreas": "Aspectos a mejorar",
+ "sparringScorecardUnavailable": "Aún no hay una tarjeta final de puntuación.",
+ "sparringNoTranscript": "No se enviaron intervenciones en esta ronda."
+ },
+ "sparring": {
+ "pro": "A favor",
+ "con": "En contra",
+ "user": "Tú",
+ "opponent": "Oponente de IA",
+ "format": {
+ "standard": "Formato escolar 4-6-6-3",
+ "ld": "Lincoln-Douglas",
+ "pf": "Foro público"
+ },
+ "formatDescription": {
+ "standard": "El formato del colegio: Introductor, R1, R2 y Conclusor para ambos lados.",
+ "ld": "Ronda de valores centrada con discursos constructivos y dos etapas de refutación.",
+ "pf": "Constructivos, refutaciones, resúmenes y enfoque final para ambos lados."
+ },
+ "turns": {
+ "standard-1": "Introductor a favor",
+ "standard-2": "Introductor en contra",
+ "standard-3": "R1 a favor",
+ "standard-4": "R1 en contra",
+ "standard-5": "R2 a favor",
+ "standard-6": "R2 en contra",
+ "standard-7": "Conclusor a favor",
+ "standard-8": "Conclusor en contra",
+ "ld-1": "Constructivo afirmativo",
+ "ld-2": "Constructivo negativo",
+ "ld-3": "Primera refutación afirmativa",
+ "ld-4": "Refutación negativa",
+ "ld-5": "Segunda refutación afirmativa",
+ "pf-1": "Constructivo a favor",
+ "pf-2": "Constructivo en contra",
+ "pf-3": "Refutación a favor",
+ "pf-4": "Refutación en contra",
+ "pf-5": "Resumen a favor",
+ "pf-6": "Resumen en contra",
+ "pf-7": "Enfoque final a favor",
+ "pf-8": "Enfoque final en contra"
+ },
+ "difficulty": {
+ "easy": "Calentamiento",
+ "medium": "Torneo",
+ "hard": "Implacable"
+ },
+ "roleGuidance": {
+ "introductor": "Abre con un exordio, define los términos clave, fija el marco y la tesis, y presenta la hoja de ruta. Reserva los argumentos completos para R1.",
+ "r1": "Construye los argumentos centrales con afirmaciones, razonamientos, apoyo e impactos. Usa la parte final para anticipar o responder a la oposición más fuerte.",
+ "r2": "Amplía R1, responde directamente al oponente, identifica los choques centrales y compara los impactos. No te limites a repetir el caso.",
+ "conclusor": "No añadas argumentos nuevos. Cristaliza los choques decisivos, sopesa la ronda y cierra explicando por qué gana tu lado."
+ },
+ "category": {
+ "Logic": "Lógica",
+ "Evidence": "Evidencia",
+ "Delivery": "Presentación",
+ "Strategy": "Estrategia"
+ },
+ "setup": {
+ "liveLabel": "Sala de práctica en directo",
+ "heading": "Entrena con un oponente que escucha.",
+ "subtitle": "Elige una moción, toma una postura y realiza una ronda cronometrada. La IA mantiene la transcripción a la vista, responde a los argumentos que realmente expones y evalúa el debate escrito al final.",
+ "onlineReady": "Listo para llamar al entrenador de debate",
+ "offlineDraft": "Sin conexión. Tu borrador se queda en esta pestaña.",
+ "setMotion": "Define la moción",
+ "setMotionDescription": "Escribe la resolución como la escucharías en la sala.",
+ "motion": "Moción",
+ "motionPlaceholder": "Esta cámara exigiría a las empresas de redes sociales verificar la edad de cada usuario.",
+ "motionHelp": "Una moción precisa da al oponente algo concreto que atacar.",
+ "motionTooShort": "Añade un poco más de detalle para que la moción se pueda juzgar.",
+ "pickSide": "Elige tu postura",
+ "pickSideDescription": "Escribirás cada intervención del lado que elijas.",
+ "proDescription": "Defiende la resolución y demuestra por qué debería aprobarse.",
+ "conDescription": "Cuestiona la resolución y demuestra por qué debería rechazarse.",
+ "choosePressure": "Elige la presión",
+ "choosePressureDescription": "El oponente adapta sus argumentos a este nivel.",
+ "difficultyDescription": {
+ "easy": "Argumentos claros y aperturas generosas.",
+ "medium": "Un oponente competente que sigue el flujo.",
+ "hard": "Contraargumentos rápidos que buscan cada hueco."
+ },
+ "roundFormat": "Formato de la ronda",
+ "roundFormatDescription": "Define el orden de las intervenciones.",
+ "speeches": "{{count}} intervenciones",
+ "aboutMinutes": "Unos {{count}} minutos",
+ "roomAtGlance": "Tu sala de un vistazo",
+ "speakingPlan": "Plan de intervenciones",
+ "turns": "{{count}} turnos",
+ "opponentTurns": "Turnos del oponente",
+ "aiRequests": "Solicitudes a la IA",
+ "aiRequestBreakdown": "{{count}} respuestas + 1 juez",
+ "aiModel": "Modelo de IA",
+ "modelHelp": "La configuración del modelo sigue el perfil de sparring en Ajustes.",
+ "openingRoom": "Abriendo la sala...",
+ "startRound": "Iniciar ronda",
+ "draftNotice": "Tu borrador se guarda en esta pestaña mientras escribes. Puedes pausar sin perder tu lugar.",
+ "offlineCannotStart": "Vuelve a conectarte para iniciar una ronda. La configuración se conserva mientras estás sin conexión."
+ },
+ "round": {
+ "inProgress": "Ronda en curso",
+ "aiRequestFailed": "Falló la solicitud a la IA. Comprueba tu conexión e inténtalo de nuevo.",
+ "judgeRequestFailed": "Falló la solicitud al juez. Comprueba tu conexión e inténtalo de nuevo.",
+ "aiOffline": "Vuelve a conectarte para que responda el oponente. Tu transcripción está a salvo.",
+ "judgeOffline": "Vuelve a conectarte para puntuar la ronda. Tu transcripción está a salvo.",
+ "backToWorkspace": "Volver al espacio de trabajo",
+ "draftSavedNotice": "Los borradores se guardan en esta pestaña",
+ "connected": "Conectado",
+ "offlineDraft": "Sin conexión. Borrador guardado localmente.",
+ "totalWords": "{{count}} palabras",
+ "complete": "Ronda terminada",
+ "turnOf": "Turno {{current}} de {{total}}",
+ "preparingResult": "Preparando tu resultado",
+ "judgeReading": "El juez está leyendo la transcripción.",
+ "scoring": "Puntuando la ronda",
+ "noTimer": "No hay cronómetro durante la evaluación",
+ "timeRemaining": "{{time}} restantes",
+ "pauseClock": "Pausar cronómetro",
+ "resumeClock": "Reanudar cronómetro",
+ "resetTurn": "Reiniciar turno",
+ "clockNote": "El cronómetro usa la fecha límite guardada, no el foco de la pestaña.",
+ "scoreFailed": "No se pudo puntuar esta ronda",
+ "retryScoring": "Intentar puntuar de nuevo",
+ "judgeWeighing": "El juez está valorando la ronda",
+ "judgeDescription": "Está leyendo las afirmaciones, respuestas y decisiones estratégicas. Esto suele requerir una solicitud.",
+ "yourSpeech": "Tu intervención",
+ "yourSpeechDescription": "Expón el caso con el tiempo que tienes. El oponente responderá a este texto.",
+ "wordCount": "{{count}} palabras",
+ "speechPlaceholder": "Desarrolla tu afirmación, razonamiento e impacto...",
+ "draftFor": "Borrador para {{label}}",
+ "toSubmit": "para enviar",
+ "submitSpeech": "Enviar intervención",
+ "timeExpired": "El tiempo de esta intervención ha terminado. Reinicia el turno para continuar.",
+ "speechRejected": "No se pudo enviar esta intervención. Comprueba el cronómetro y la longitud e inténtalo de nuevo.",
+ "offlineCannotSubmit": "Vuelve a conectarte para enviar esta intervención.",
+ "opponentFailed": "El oponente no pudo responder",
+ "retryOpponent": "Reintentar turno del oponente",
+ "offlineRetry": "Vuelve a conectarte antes de reintentarlo.",
+ "opponentThinking": "El oponente está pensando",
+ "opponentUpNext": "El oponente va ahora",
+ "opponentThinkingDescription": "Está leyendo la moción y tus intervenciones antes de elegir una respuesta.",
+ "opponentUpNextDescription": "La siguiente respuesta aparecerá aquí.",
+ "aiThinking": "La IA está pensando",
+ "transcript": "Transcripción",
+ "transcriptDescription": "Cada intervención enviada se queda aquí para el juez.",
+ "submittedCount": "{{count}} enviadas",
+ "transcriptEmpty": "Tu primera intervención aparecerá aquí después de enviarla.",
+ "roundPlan": "Plan de la ronda",
+ "completeStatus": "Completado",
+ "timeUsed": "{{time}} usados",
+ "estimatedTimeUsed": "{{time}} estimados",
+ "youArePro": "Estás a favor",
+ "youAreCon": "Estás en contra"
+ },
+ "scorecard": {
+ "eyebrow": "Revisión de la ronda",
+ "heading": "Esto es lo que pasó en la sala.",
+ "exportDocx": "Exportar DOCX",
+ "exportPdf": "PDF",
+ "restart": "Iniciar otra ronda",
+ "finalResult": "Resultado final",
+ "userWon": "Ganaste la votación",
+ "opponentWon": "El oponente de IA ganó la votación",
+ "tie": "La ronda terminó en empate",
+ "tied": "Empate",
+ "winner": "Ganador",
+ "keepTraining": "Sigue entrenando",
+ "yourScore": "Tu puntuación",
+ "opponentScore": "Puntuación del oponente",
+ "topScore": "Mejor puntuación",
+ "verdict": "Veredicto del juez",
+ "categoryBalance": "Equilibrio por categoría",
+ "categoryDescription": "Cada categoría se puntúa sobre 25.",
+ "radarLabel": "Gráfico radial que compara las puntuaciones por categoría",
+ "radarNote": "Solo es un resumen visual. Lee las puntuaciones y los comentarios de abajo para obtener la devolución completa.",
+ "categoryNotes": "Notas por categoría",
+ "categoryNotesDescription": "Motivos específicos detrás de cada puntuación. La presentación se juzga a partir del discurso escrito, no del audio.",
+ "writtenOnly": "solo escrito",
+ "accessibleCategoryScore": "Tu puntuación es {{user}} de 25. La puntuación del oponente es {{opponent}} de 25.",
+ "aiShort": "IA",
+ "keyMoments": "Momentos clave",
+ "noKeyMoments": "No se registraron momentos clave.",
+ "workOnNext": "Trabaja después en",
+ "noImprovementAreas": "No se registraron áreas de mejora.",
+ "overallFeedback": "Comentarios generales",
+ "fullTranscript": "Transcripción completa",
+ "transcriptDescription": "{{count}} intervenciones de esta ronda {{format}}.",
+ "hideTranscript": "Ocultar transcripción",
+ "showTranscript": "Mostrar transcripción"
+ }
},
"langSwitcher": {
"label": "Idioma",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 8ad5c178b..0616ec5bd 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -9,6 +9,7 @@
"crossfire": "Keresztkérdezés",
"library": "Könyvtár",
"timer": "Időmérő",
+ "sparring": "Vitapárbaj",
"flowchart": "Folyamatábra",
"history": "Előzmények",
"features": "Funkciók",
@@ -30,6 +31,7 @@
"crossfire": "Élő keresztkérdezés gyakorlása",
"library": "Források kezelése",
"timer": "Időzítés gyakorlása",
+ "sparring": "Élő vita forduló gyakorlása",
"flowchart": "Érvek térképezése",
"history": "Mentett elemek"
},
@@ -63,6 +65,8 @@
"libraryDesc": "Kezeld és kapcsold a megalapozott válaszokhoz használt forrásokat.",
"timer": "Időmérő",
"timerDesc": "Gyakorolj beszédrundákat versenyszintű időzítéssel.",
+ "sparring": "Vitapárbaj",
+ "sparringDesc": "Gyakorolj egy időzített vitafordulót MI-ellenféllel, majd nézd át az értékelőlapot.",
"flowchart": "Folyamatábra",
"flowchartDesc": "Térképezd az érveket és cáfolati kapcsolatokat vizuális vitafában.",
"history": "Előzmények",
@@ -157,6 +161,7 @@
"signedInAs": "Bejelentkezve mint",
"unknownUser": "Ismeretlen felhasználó",
"signOut": "Kijelentkezés",
+ "signOutFailed": "Nem sikerült kijelentkezni. Próbáld újra.",
"aiModels": "MI modellek",
"aiModelsDesc": "A kívánt modellt az egyes funkcióoldalak fejlécében lévő modellválasztóval állíthatod be.",
"admin": "Admin",
@@ -902,6 +907,7 @@
"tabAttacks": "Támadások",
"tabBrainstorms": "Ötletelések",
"tabFactoids": "Tények",
+ "tabSparring": "Vitapárbaj",
"casesTitle": "Mentett Esetek",
"casesDesc": "A generált vitaeseteid",
"chatsTitle": "Chat Előzmények",
@@ -927,6 +933,17 @@
"attackCase": "Eset támadása",
"brainstormIdeas": "Ötletelés",
"findEvidence": "Bizonyíték keresése",
+ "sparringTitle": "Vitapárbaj fordulók",
+ "sparringDesc": "Befejezett időzített viták értékelőlapokkal és átiratokkal",
+ "emptySparring": "Még nincs vitapárbaj forduló",
+ "emptySparringDesc": "Fejezz be egy időzített vitafordulót, hogy itt átnézhesd.",
+ "startSparring": "Vitapárbaj indítása",
+ "score": "Te {{user}} - MI {{ai}}",
+ "deleteSparringAria": "Vitapárbaj forduló törlése: {{motion}}",
+ "deleteSparringTitle": "Törlöd a vitapárbaj fordulót?",
+ "deleteSparringDesc": "Ez eltávolítja az értékelőlapot és az átiratot az előzményeidből.",
+ "sparringDeleted": "A vitapárbaj forduló törölve.",
+ "sparringDeleteFailed": "Nem sikerült törölni a vitapárbaj fordulót.",
"load": "Betöltés",
"resume": "Folytatás",
"messages": "üzenet"
@@ -1127,7 +1144,216 @@
"counters": "Előkészített Ellenérvek",
"weaknesses": "Azonosított Gyengeségek",
"fallacies": "Észlelt Téves Következtetések",
- "crossExQuestions": "Keresztkérdezési Kérdések"
+ "crossExQuestions": "Keresztkérdezési Kérdések",
+ "motion": "Indítvány",
+ "sparringTranscript": "Vitapárbaj átirata",
+ "sparringConfiguration": "Forduló beállításai",
+ "sparringFormat": "Formátum",
+ "sparringDifficulty": "Nehézség",
+ "sparringCompleted": "Befejezve",
+ "sparringTimeUsed": "Felhasznált idő",
+ "sparringTotals": "Végeredmény",
+ "sparringUser": "Te",
+ "sparringOpponent": "MI ellenfél",
+ "sparringWinner": "Győztes",
+ "sparringTie": "Döntetlen",
+ "sparringScore": "Pontszám",
+ "sparringResult": "Eredmény",
+ "sparringSpeeches": "Beszédek",
+ "sparringCategories": "Kategóriapontszámok",
+ "sparringCategory": "Kategória",
+ "sparringFeedback": "Visszajelzés",
+ "sparringKeyMoments": "Kulcspillanatok",
+ "sparringImprovementAreas": "Következő fejlesztési pontok",
+ "sparringScorecardUnavailable": "Még nincs végleges értékelőlap.",
+ "sparringNoTranscript": "Ebben a fordulóban nem küldtek be beszédet."
+ },
+ "sparring": {
+ "pro": "Mellette",
+ "con": "Ellene",
+ "user": "Te",
+ "opponent": "MI ellenfél",
+ "format": {
+ "standard": "Iskolai 4-6-6-3",
+ "ld": "Lincoln-Douglas",
+ "pf": "Public Forum"
+ },
+ "formatDescription": {
+ "standard": "Az iskolai formátum: Introductor, R1, R2 és Conclusor mindkét oldalon.",
+ "ld": "Értékalapú forduló konstruktív beszédekkel és két cáfolati szakasszal.",
+ "pf": "Konstruktív beszédek, cáfolatok, összegzések és végső fókusz mindkét oldalnak."
+ },
+ "turns": {
+ "standard-1": "Pro Introductor",
+ "standard-2": "Kontra Introductor",
+ "standard-3": "Pro R1",
+ "standard-4": "Kontra R1",
+ "standard-5": "Pro R2",
+ "standard-6": "Kontra R2",
+ "standard-7": "Pro Conclusor",
+ "standard-8": "Kontra Conclusor",
+ "ld-1": "Affirmatív érvelés",
+ "ld-2": "Negatív érvelés",
+ "ld-3": "Első affirmatív cáfolat",
+ "ld-4": "Negatív cáfolat",
+ "ld-5": "Második affirmatív cáfolat",
+ "pf-1": "Pro érvelés",
+ "pf-2": "Kontra érvelés",
+ "pf-3": "Pro cáfolat",
+ "pf-4": "Kontra cáfolat",
+ "pf-5": "Pro összegzés",
+ "pf-6": "Kontra összegzés",
+ "pf-7": "Pro végső fókusz",
+ "pf-8": "Kontra végső fókusz"
+ },
+ "difficulty": {
+ "easy": "Bemelegítés",
+ "medium": "Verseny",
+ "hard": "Kíméletlen"
+ },
+ "roleGuidance": {
+ "introductor": "Kezdj exordióval, határozd meg a kulcsfogalmakat, állítsd fel a keretet és a tézist, majd ismertesd az érvelés menetét. A teljes érveket hagyd az R1-re.",
+ "r1": "Építsd fel a fő érveket állítással, indoklással, alátámasztással és hatással. A végén előzd meg vagy válaszold meg a legerősebb ellenérvet.",
+ "r2": "Mélyítsd el az R1 érveit, válaszolj közvetlenül az ellenfélnek, nevezd meg a fő ütközési pontokat, és mérd össze a hatásokat. Ne csak ismételd az esetet.",
+ "conclusor": "Ne hozz új érvet. Kristályosítsd ki a döntő ütközési pontokat, mérlegeld a fordulót, és zárd le azzal, miért a te oldalad nyer."
+ },
+ "category": {
+ "Logic": "Logika",
+ "Evidence": "Bizonyíték",
+ "Delivery": "Előadás",
+ "Strategy": "Stratégia"
+ },
+ "setup": {
+ "liveLabel": "Élő gyakorlószoba",
+ "heading": "Vitatkozz egy figyelő ellenféllel.",
+ "subtitle": "Válassz indítványt, foglalj állást, és menj végig egy időzített fordulón. Az MI szem előtt tartja az átiratot, az általad ténylegesen felhozott érvekre válaszol, majd a végén értékeli az írásos vitát.",
+ "onlineReady": "A vitaedző hívásra kész",
+ "offlineDraft": "Offline vagy. A piszkozat ezen a böngészőfülön marad.",
+ "setMotion": "Indítvány megadása",
+ "setMotionDescription": "Írd le a határozati javaslatot úgy, ahogy a teremben elhangzana.",
+ "motion": "Indítvány",
+ "motionPlaceholder": "Ez a Ház kötelezné a közösségimédia-vállalatokat minden felhasználó életkorának ellenőrzésére.",
+ "motionHelp": "Egy pontos indítvány konkrét támadási felületet ad az ellenfélnek.",
+ "motionTooShort": "Adj hozzá több részletet, hogy az indítvány értékelhető legyen.",
+ "pickSide": "Válaszd ki az oldalad",
+ "pickSideDescription": "A kiválasztott oldal minden beszédét te írod.",
+ "proDescription": "Védd az indítványt, és mutasd meg, miért kell elfogadni.",
+ "conDescription": "Kérdőjelezd meg az indítványt, és mutasd meg, miért kell elutasítani.",
+ "choosePressure": "Válaszd ki a nehézséget",
+ "choosePressureDescription": "Az ellenfél ehhez a szinthez igazítja az érveit.",
+ "difficultyDescription": {
+ "easy": "Világos érvek és nagyvonalú nyitások.",
+ "medium": "Egy rátermett ellenfél, aki követi a vitafolyamot.",
+ "hard": "Gyors ellenérvek, amelyek minden rést megkeresnek."
+ },
+ "roundFormat": "Fordulóformátum",
+ "roundFormatDescription": "Állítsd be a beszédek sorrendjét.",
+ "speeches": "{{count}} beszéd",
+ "aboutMinutes": "Körülbelül {{count}} perc",
+ "roomAtGlance": "Szobád áttekintése",
+ "speakingPlan": "Beszédterv",
+ "turns": "{{count}} kör",
+ "opponentTurns": "Az ellenfél körei",
+ "aiRequests": "MI-kérések",
+ "aiRequestBreakdown": "{{count}} válasz + 1 bíró",
+ "aiModel": "MI-modell",
+ "modelHelp": "A modellbeállítások a Beállítások vitapárbaj profilját követik.",
+ "openingRoom": "Szoba megnyitása...",
+ "startRound": "Forduló indítása",
+ "draftNotice": "A piszkozat írás közben ezen a böngészőfülön mentődik. Szüneteltetheted anélkül, hogy elveszítenéd a helyed.",
+ "offlineCannotStart": "Csatlakozz újra a forduló indításához. A beállításaid offline állapotban is megmaradnak."
+ },
+ "round": {
+ "inProgress": "Forduló folyamatban",
+ "aiRequestFailed": "Az MI-kérés sikertelen volt. Ellenőrizd a kapcsolatot, majd próbáld újra.",
+ "judgeRequestFailed": "A bírói kérés sikertelen volt. Ellenőrizd a kapcsolatot, majd próbáld újra.",
+ "aiOffline": "Csatlakozz újra, hogy az ellenfél válaszolhasson. Az átiratod biztonságban van.",
+ "judgeOffline": "Csatlakozz újra a forduló értékeléséhez. Az átiratod biztonságban van.",
+ "backToWorkspace": "Vissza a munkaterülethez",
+ "draftSavedNotice": "A piszkozatok ezen a böngészőfülön mentődnek",
+ "connected": "Csatlakozva",
+ "offlineDraft": "Offline vagy. A piszkozat helyben mentve.",
+ "totalWords": "{{count}} szó",
+ "complete": "Forduló véget ért",
+ "turnOf": "{{current}}. kör a(z) {{total}} közül",
+ "preparingResult": "Eredmény előkészítése",
+ "judgeReading": "A bíró olvassa az átiratot.",
+ "scoring": "Forduló értékelése",
+ "noTimer": "Értékelés közben nincs óra",
+ "timeRemaining": "{{time}} van hátra",
+ "pauseClock": "Óra szüneteltetése",
+ "resumeClock": "Óra folytatása",
+ "resetTurn": "Kör visszaállítása",
+ "clockNote": "Az óra a mentett határidőt használja, nem a böngészőfül fókuszát.",
+ "scoreFailed": "Nem sikerült értékelni ezt a fordulót",
+ "retryScoring": "Értékelés újrapróbálása",
+ "judgeWeighing": "A bíró mérlegeli a fordulót",
+ "judgeDescription": "Az állításokat, válaszokat és stratégiai döntéseket olvassa. Ehhez általában egy kérés kell.",
+ "yourSpeech": "A te beszéded",
+ "yourSpeechDescription": "Építsd fel az érvelést a rendelkezésedre álló időben. Az ellenfél erre a szövegre válaszol.",
+ "wordCount": "{{count}} szó",
+ "speechPlaceholder": "Építsd fel az állítást, az indoklást és a hatást...",
+ "draftFor": "Piszkozat: {{label}}",
+ "toSubmit": "beküldéshez",
+ "submitSpeech": "Beszéd beküldése",
+ "timeExpired": "A felszólalási idő lejárt. A folytatáshoz állítsd vissza a kört.",
+ "speechRejected": "A beszédet nem sikerült beküldeni. Ellenőrizd az időt és a hosszt, majd próbáld újra.",
+ "offlineCannotSubmit": "Csatlakozz újra a beszéd beküldéséhez.",
+ "opponentFailed": "Az ellenfél nem tudott válaszolni",
+ "retryOpponent": "Az ellenfél körének újrapróbálása",
+ "offlineRetry": "Újrapróbálás előtt csatlakozz újra.",
+ "opponentThinking": "Az ellenfél gondolkodik",
+ "opponentUpNext": "Az ellenfél következik",
+ "opponentThinkingDescription": "A válasz előtt elolvassa az indítványt és a beszédeidet.",
+ "opponentUpNextDescription": "A következő válasz itt jelenik meg.",
+ "aiThinking": "Az MI gondolkodik",
+ "transcript": "Átirat",
+ "transcriptDescription": "Minden beküldött beszéd itt marad a bíró számára.",
+ "submittedCount": "{{count}} beküldve",
+ "transcriptEmpty": "Az első beszéded a beküldés után itt jelenik meg.",
+ "roundPlan": "Körterv",
+ "completeStatus": "Kész",
+ "timeUsed": "{{time}} felhasználva",
+ "estimatedTimeUsed": "Becsült idő: {{time}}",
+ "youArePro": "Te vagy mellette",
+ "youAreCon": "Te vagy ellene"
+ },
+ "scorecard": {
+ "eyebrow": "Forduló értékelése",
+ "heading": "Ez történt a teremben.",
+ "exportDocx": "DOCX exportálása",
+ "exportPdf": "PDF",
+ "restart": "Új forduló indítása",
+ "finalResult": "Végeredmény",
+ "userWon": "Te nyerted a szavazatot",
+ "opponentWon": "Az MI-ellenfél nyerte a szavazatot",
+ "tie": "A forduló döntetlen lett",
+ "tied": "Döntetlen",
+ "winner": "Győztes",
+ "keepTraining": "Folytasd a gyakorlást",
+ "yourScore": "A pontszámod",
+ "opponentScore": "Az ellenfél pontszáma",
+ "topScore": "Legjobb pontszám",
+ "verdict": "A bíró ítélete",
+ "categoryBalance": "Kategóriaegyensúly",
+ "categoryDescription": "Minden kategória 25 pontból áll.",
+ "radarLabel": "A kategóriapontszámokat összehasonlító sugárdiagram",
+ "radarNote": "Ez csak vizuális összegzés. A teljes visszajelzéshez olvasd el az alábbi szöveges pontszámokat.",
+ "categoryNotes": "Kategóriajegyzetek",
+ "categoryNotesDescription": "Minden pontszám mögött konkrét indokok állnak. Az előadást az írásos beszéd alapján értékeljük, nem hang alapján.",
+ "writtenOnly": "csak írásban",
+ "accessibleCategoryScore": "A pontszámod {{user}} a 25-ből. Az ellenfél pontszáma {{opponent}} a 25-ből.",
+ "aiShort": "MI",
+ "keyMoments": "Kulcspillanatok",
+ "noKeyMoments": "Nem rögzítettek kulcspillanatot.",
+ "workOnNext": "Következő fejlesztési pontok",
+ "noImprovementAreas": "Nem rögzítettek fejlesztési területet.",
+ "overallFeedback": "Általános visszajelzés",
+ "fullTranscript": "Teljes átirat",
+ "transcriptDescription": "{{count}} beszéd ebből a(z) {{format}} fordulóból.",
+ "hideTranscript": "Átirat elrejtése",
+ "showTranscript": "Átirat megjelenítése"
+ }
},
"langSwitcher": {
"label": "Nyelv",
@@ -1140,7 +1366,7 @@
"hu": "Magyar"
},
"crossfire": {
- "openFromResult": "Megnyitás a Crossfire-ban",
+ "openFromResult": "Megnyitás a Keresztkérdezésben",
"validationError": "Kezdés előtt add meg a tételt és az érvelést.",
"storageError": "A böngésző nem tudta helyben menteni a befejezetlen alkalmat.",
"noGroundedFinding": "Ehhez nincs a leirat által alátámasztott megállapítás.",
diff --git a/src/offline-route-preload.ts b/src/offline-route-preload.ts
index 0aa4f3e43..b35399e9e 100644
--- a/src/offline-route-preload.ts
+++ b/src/offline-route-preload.ts
@@ -9,6 +9,7 @@ const preloaders = [
() => import('./pages/HistoryPage'),
() => import('./pages/LibraryPage'),
() => import('./pages/TimerPage'),
+ () => import('./pages/SparringPage'),
() => import('./pages/FlowchartPage'),
() => import('./pages/SettingsPage'),
() => import('./pages/AdminPage'),
diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx
index 76507e224..5cb5b9858 100644
--- a/src/pages/HistoryPage.tsx
+++ b/src/pages/HistoryPage.tsx
@@ -1,10 +1,10 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSessionStore } from '@/lib/stores/session-store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
-import { Trash2, Eye, MessageCircle, FileText, Zap, Brain, BookOpen } from 'lucide-react';
+import { Trash2, Eye, MessageCircle, FileText, Zap, Brain, BookOpen, Dumbbell } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -21,13 +21,23 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { EmptyState } from '@/components/ui/empty-state';
+import { useSparringStore } from '@/lib/stores/sparring-store';
export default function HistoryPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const store = useSessionStore();
+ const sparringSessions = useSparringStore((state) => state.savedSessions);
+ const hydrateSparringSessions = useSparringStore((state) => state.hydrateSavedSessions);
+ const loadSparringSession = useSparringStore((state) => state.loadSavedSession);
+ const deleteSparringSession = useSparringStore((state) => state.deleteSavedSession);
const { toast } = useToast();
const [caseToDelete, setCaseToDelete] = useState<{ id: string; title: string } | null>(null);
+ const [sparringSessionToDelete, setSparringSessionToDelete] = useState<{ id: string; motion: string } | null>(null);
+
+ useEffect(() => {
+ void hydrateSparringSessions();
+ }, [hydrateSparringSessions]);
const handleLoadCase = (id: string) => {
store.loadSavedCase(id);
@@ -66,6 +76,23 @@ export default function HistoryPage() {
setCaseToDelete(null);
};
+ const handleLoadSparringSession = (id: string) => {
+ loadSparringSession(id);
+ navigate('/app/sparring');
+ };
+
+ const confirmDeleteSparringSession = () => {
+ if (!sparringSessionToDelete) return;
+ void deleteSparringSession(sparringSessionToDelete.id)
+ .then(() => {
+ toast({ title: t('common.deleted', 'Deleted'), description: t('historyPage.sparringDeleted') });
+ })
+ .catch(() => {
+ toast({ title: t('common.error', 'Error'), description: t('historyPage.sparringDeleteFailed'), variant: 'destructive' });
+ });
+ setSparringSessionToDelete(null);
+ };
+
return (
@@ -77,12 +104,13 @@ export default function HistoryPage() {
-
+
{t('historyPage.tabCases')}
{t('historyPage.tabChats')}
{t('historyPage.tabAttacks')}
{t('historyPage.tabBrainstorms')}
{t('historyPage.tabFactoids')}
+ {t('historyPage.tabSparring')}
@@ -262,6 +290,57 @@ export default function HistoryPage() {
+
+
+
+ {t('historyPage.sparringTitle')}
+ {t('historyPage.sparringDesc')}
+
+
+ {sparringSessions.length === 0 ? (
+ navigate('/app/sparring')}
+ />
+ ) : (
+
+
+ {sparringSessions.map((session) => (
+
+
+
{session.motion}
+
+ {t(`sparring.format.${session.format}`, session.format.toUpperCase())}
+ {session.userSide === 'pro' ? t('export.proposition') : t('export.opposition')}
+ {session.scorecard && {t('historyPage.score', { user: session.scorecard.userScore, ai: session.scorecard.aiScore })} }
+ {formatEuropeanDateTime(session.updatedAt)}
+
+
+
+ handleLoadSparringSession(session.id)}>
+ {t('historyPage.load')}
+
+ setSparringSessionToDelete({ id: session.id, motion: session.motion })}
+ aria-label={t('historyPage.deleteSparringAria', { motion: session.motion })}
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
!open && setCaseToDelete(null)}>
@@ -277,6 +356,20 @@ export default function HistoryPage() {
+
!open && setSparringSessionToDelete(null)}>
+
+
+ {t('historyPage.deleteSparringTitle')}
+
+ {t('historyPage.deleteSparringDesc')}
+
+
+
+ {t('common.cancel', 'Cancel')}
+ {t('common.delete', 'Delete')}
+
+
+
);
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index da395711c..ce899500d 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -9,6 +9,7 @@ import {
Crosshair,
Database,
Timer,
+ Dumbbell,
GitBranch,
Clock,
ArrowRight
@@ -40,6 +41,27 @@ export default function HomePage() {