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