From 46a58145a35cee109ce092511dad3bea2fb7ff76 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 17:45:24 +0000 Subject: [PATCH 01/11] Move the free tools' timer, formats, speech time and motion bank into core The app's timer, speech-time calculator and motion generator need the same logic as the website's free tools. Each old src/lib path re-exports the core module; the custom format's URL codec stays in the website, and core gains parseCustomFormat for formats kept on a device. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/free-tools/custom-format.ts | 106 + packages/core/src/free-tools/formats.ts | 245 ++ packages/core/src/free-tools/motion-bank.ts | 96 + packages/core/src/free-tools/motion-types.ts | 36 + .../core/src/free-tools/practice-motions.ts | 2436 ++++++++++++++++ packages/core/src/free-tools/round-timer.ts | 167 ++ packages/core/src/free-tools/speech-time.ts | 60 + packages/core/src/globals.d.ts | 4 +- packages/core/src/timer-clock.ts | 29 + src/lib/free-tools/custom-format.ts | 82 +- src/lib/free-tools/formats.ts | 246 +- src/lib/free-tools/motion-bank.ts | 97 +- src/lib/free-tools/motion-types.ts | 37 +- src/lib/free-tools/practice-motions.ts | 2437 +---------------- src/lib/free-tools/round-timer.ts | 168 +- src/lib/free-tools/speech-time.ts | 61 +- src/lib/timer-clock.ts | 30 +- 17 files changed, 3190 insertions(+), 3147 deletions(-) create mode 100644 packages/core/src/free-tools/custom-format.ts create mode 100644 packages/core/src/free-tools/formats.ts create mode 100644 packages/core/src/free-tools/motion-bank.ts create mode 100644 packages/core/src/free-tools/motion-types.ts create mode 100644 packages/core/src/free-tools/practice-motions.ts create mode 100644 packages/core/src/free-tools/round-timer.ts create mode 100644 packages/core/src/free-tools/speech-time.ts create mode 100644 packages/core/src/timer-clock.ts diff --git a/packages/core/src/free-tools/custom-format.ts b/packages/core/src/free-tools/custom-format.ts new file mode 100644 index 000000000..52132047f --- /dev/null +++ b/packages/core/src/free-tools/custom-format.ts @@ -0,0 +1,106 @@ +/** + * A coach's own round format and the limits it must fit. The website shares + * one in a URL (`?custom=`, src/lib/free-tools/custom-format.ts); the app + * keeps them on the device. + */ +import type { RoundFormat, RoundSegment, SegmentSide } from './formats'; + +export interface CustomSegment { + label: string; + seconds: number; + /** Questioning periods have no points of information and no flow column. */ + kind: 'speech' | 'cross-examination'; + side: SegmentSide; + /** Points of information between the first and last minute. */ + poi: boolean; +} + +export interface CustomFormat { + name: string; + /** Prep time per side, in seconds. */ + prepSeconds: number; + segments: CustomSegment[]; +} + +export const CUSTOM_LIMITS = { + segments: 30, + label: 40, + name: 60, + minSeconds: 10, + maxSeconds: 60 * 60, + maxPrepSeconds: 30 * 60, + /** Encoded length; well under any browser's URL limit. */ + encoded: 4000, +} as const; + +/** Control characters become spaces, runs of space collapse, and the result is cut to `max`. */ +export function cleanText(value: string, max: number): string { + const visible = Array.from(value, (char) => (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 ? ' ' : char)).join(''); + return visible.replace(/\s+/g, ' ').trim().slice(0, max); +} + +export const isWholeSeconds = (value: unknown, min: number, max: number): value is number => + Number.isInteger(value) && Number(value) >= min && Number(value) <= max; + +/** + * A custom format read back from storage, held to the same limits as a + * shared link; null when anything does not fit. Text is cleaned, not refused. + */ +export function parseCustomFormat(value: unknown): CustomFormat | null { + if (typeof value !== 'object' || value === null) return null; + const { name, prepSeconds, segments } = value as Record; + if (typeof name !== 'string' || !isWholeSeconds(prepSeconds, 0, CUSTOM_LIMITS.maxPrepSeconds)) return null; + if (!Array.isArray(segments) || segments.length === 0 || segments.length > CUSTOM_LIMITS.segments) return null; + const parsed: CustomSegment[] = []; + for (const entry of segments) { + if (typeof entry !== 'object' || entry === null) return null; + const { label, seconds, kind, side, poi } = entry as Record; + if (typeof label !== 'string' || !cleanText(label, CUSTOM_LIMITS.label)) return null; + if (!isWholeSeconds(seconds, CUSTOM_LIMITS.minSeconds, CUSTOM_LIMITS.maxSeconds)) return null; + if (kind !== 'speech' && kind !== 'cross-examination') return null; + if (side !== 'a' && side !== 'b' && side !== 'both') return null; + if (typeof poi !== 'boolean') return null; + parsed.push({ label: cleanText(label, CUSTOM_LIMITS.label), seconds, kind, side, poi: poi && kind === 'speech' }); + } + return { name: cleanText(name, CUSTOM_LIMITS.name) || 'Custom format', prepSeconds, segments: parsed }; +} + +/** The timer's view of a custom format. POI windows need at least a minute between the protected ends. */ +export function customToRoundFormat(custom: CustomFormat): RoundFormat { + const segments: RoundSegment[] = custom.segments.map((segment, index) => ({ + id: `c${index + 1}`, + short: segment.label, + label: segment.label, + side: segment.side, + kind: segment.kind, + seconds: segment.seconds, + ...(segment.poi && segment.kind === 'speech' && segment.seconds >= 180 + ? { poi: { opensAt: 60, closesAt: segment.seconds - 60 } } + : {}), + })); + return { + id: 'custom', + name: custom.name, + shortName: 'Custom', + sideNames: ['Side A', 'Side B'], + segments, + prepSeconds: custom.prepSeconds, + sources: [], + note: 'A custom format. Share its link so your club times rounds the same way.', + }; +} + +/** A starting point for editing: a built-in format as a custom one. */ +export function roundFormatToCustom(format: RoundFormat): CustomFormat { + return { + name: `${format.name} (custom)`.slice(0, CUSTOM_LIMITS.name), + prepSeconds: format.prepSeconds, + segments: format.segments.map((segment) => ({ + label: segment.short.slice(0, CUSTOM_LIMITS.label), + seconds: segment.seconds, + kind: segment.kind === 'speech' || segment.kind === 'reply' ? 'speech' : 'cross-examination', + side: segment.side, + poi: Boolean(segment.poi), + })), + }; +} diff --git a/packages/core/src/free-tools/formats.ts b/packages/core/src/free-tools/formats.ts new file mode 100644 index 000000000..80d942a39 --- /dev/null +++ b/packages/core/src/free-tools/formats.ts @@ -0,0 +1,245 @@ +/** + * Speech orders and times for the free debate tools (timer, flow sheet, speech + * calculator). Every number comes from the source listed with its format; + * leagues and tournaments vary, so the timer lets a coach change any of them. + */ + +export type SegmentKind = 'speech' | 'reply' | 'cross-examination' | 'crossfire'; +/** `a` speaks first in the format's own terms (Affirmative, Government, Team A); `both` is a shared period. */ +export type SegmentSide = 'a' | 'b' | 'both'; + +export interface RoundSegment { + id: string; + /** Short name on buttons and flow-sheet columns: "1AC", "PM". */ + short: string; + /** Full name: "First Affirmative Constructive". */ + label: string; + side: SegmentSide; + kind: SegmentKind; + seconds: number; + /** Elapsed seconds between which points of information may be offered; outside it the speech is protected. */ + poi?: { opensAt: number; closesAt: number }; + /** What both sides' speech in this slot is called ("constructive"), when the short name names a side. */ + role?: string; +} + +export interface FormatSource { + label: string; + url: string; +} + +export interface RoundFormat { + id: string; + name: string; + shortName: string; + sideNames: readonly [string, string]; + segments: readonly RoundSegment[]; + /** In-round prep time each side may use, in seconds; 0 when the format has none. */ + prepSeconds: number; + /** Seconds past time a speech may run before judges stop listening, where the rules give one. */ + graceSeconds?: number; + sources: readonly FormatSource[]; + /** What varies between leagues, in one or two sentences. */ + note: string; +} + +export const BUILT_IN_FORMAT_IDS = ['pf', 'ld', 'policy', 'bp', 'wsdc', 'ap', 'school'] as const; +export type BuiltInFormatId = (typeof BUILT_IN_FORMAT_IDS)[number]; + +const MIN = 60; + +const NSDA_MANUAL: FormatSource = { + label: 'NSDA High School Unified Manual', + url: 'https://www.speechanddebate.org/high-school-unified-manual/', +}; + +const speech = (id: string, short: string, label: string, side: SegmentSide, seconds: number, poi?: RoundSegment['poi'], role?: string): RoundSegment => + ({ id, short, label, side, kind: 'speech', seconds, ...(poi ? { poi } : {}), ...(role ? { role } : {}) }); +const question = (id: string, short: string, label: string, side: SegmentSide, kind: 'cross-examination' | 'crossfire', seconds: number): RoundSegment => + ({ id, short, label, side, kind, seconds }); +const reply = (id: string, short: string, label: string, side: SegmentSide, seconds: number): RoundSegment => + ({ id, short, label, side, kind: 'reply', seconds, role: 'reply' }); + +/** POIs between the end of the first minute and the start of the last. */ +const protectedEnds = (seconds: number) => ({ opensAt: MIN, closesAt: seconds - MIN }); + +export const FORMATS: Record = { + pf: { + id: 'pf', + name: 'Public Forum', + shortName: 'PF', + sideNames: ['Team A', 'Team B'], + segments: [ + speech('a1', 'A Const', 'Team A constructive (first speaker)', 'a', 4 * MIN, undefined, 'constructive'), + speech('b1', 'B Const', 'Team B constructive (first speaker)', 'b', 4 * MIN, undefined, 'constructive'), + question('cf1', 'CF 1', 'Crossfire (first speakers)', 'both', 'crossfire', 3 * MIN), + speech('a2', 'A Rebuttal', 'Team A rebuttal (second speaker)', 'a', 4 * MIN, undefined, 'rebuttal'), + speech('b2', 'B Rebuttal', 'Team B rebuttal (second speaker)', 'b', 4 * MIN, undefined, 'rebuttal'), + question('cf2', 'CF 2', 'Crossfire (second speakers)', 'both', 'crossfire', 3 * MIN), + speech('a3', 'A Summary', 'Team A summary (first speaker)', 'a', 3 * MIN, undefined, 'summary'), + speech('b3', 'B Summary', 'Team B summary (first speaker)', 'b', 3 * MIN, undefined, 'summary'), + question('gcf', 'Grand CF', 'Grand crossfire (all four debaters)', 'both', 'crossfire', 3 * MIN), + speech('a4', 'A Final Focus', 'Team A final focus (second speaker)', 'a', 2 * MIN, undefined, 'final focus'), + speech('b4', 'B Final Focus', 'Team B final focus (second speaker)', 'b', 2 * MIN, undefined, 'final focus'), + ], + prepSeconds: 3 * MIN, + sources: [NSDA_MANUAL], + note: 'A coin toss decides which team speaks first, so Team A can be Pro or Con. NSDA moved summaries and prep from 2 to 3 minutes in 2020; some local leagues still use 2 and 2.', + }, + ld: { + id: 'ld', + name: 'Lincoln-Douglas', + shortName: 'LD', + sideNames: ['Affirmative', 'Negative'], + segments: [ + speech('ac', 'AC', 'Affirmative constructive', 'a', 6 * MIN), + question('cx1', 'CX', 'Negative cross-examines the Affirmative', 'b', 'cross-examination', 3 * MIN), + speech('nc', 'NC', 'Negative constructive', 'b', 7 * MIN), + question('cx2', 'CX', 'Affirmative cross-examines the Negative', 'a', 'cross-examination', 3 * MIN), + speech('1ar', '1AR', 'First affirmative rebuttal', 'a', 4 * MIN), + speech('nr', 'NR', 'Negative rebuttal', 'b', 6 * MIN), + speech('2ar', '2AR', 'Second affirmative rebuttal', 'a', 3 * MIN), + ], + prepSeconds: 4 * MIN, + sources: [NSDA_MANUAL], + note: 'Four minutes of prep per debater is the NSDA standard; some tournaments allow up to five, so check the invitation.', + }, + policy: { + id: 'policy', + name: 'Policy (CX)', + shortName: 'Policy', + sideNames: ['Affirmative', 'Negative'], + segments: [ + speech('1ac', '1AC', 'First affirmative constructive', 'a', 8 * MIN), + question('cx1', 'CX', 'Negative cross-examines the 1AC', 'b', 'cross-examination', 3 * MIN), + speech('1nc', '1NC', 'First negative constructive', 'b', 8 * MIN), + question('cx2', 'CX', 'Affirmative cross-examines the 1NC', 'a', 'cross-examination', 3 * MIN), + speech('2ac', '2AC', 'Second affirmative constructive', 'a', 8 * MIN), + question('cx3', 'CX', 'Negative cross-examines the 2AC', 'b', 'cross-examination', 3 * MIN), + speech('2nc', '2NC', 'Second negative constructive', 'b', 8 * MIN), + question('cx4', 'CX', 'Affirmative cross-examines the 2NC', 'a', 'cross-examination', 3 * MIN), + speech('1nr', '1NR', 'First negative rebuttal', 'b', 5 * MIN), + speech('1ar', '1AR', 'First affirmative rebuttal', 'a', 5 * MIN), + speech('2nr', '2NR', 'Second negative rebuttal', 'b', 5 * MIN), + speech('2ar', '2AR', 'Second affirmative rebuttal', 'a', 5 * MIN), + ], + prepSeconds: 8 * MIN, + sources: [NSDA_MANUAL], + note: 'This is the NSDA 8-3-5 structure with 8 minutes of prep per team. Older material lists 5 minutes of prep, and some state circuits still run 4-minute rebuttals.', + }, + bp: { + id: 'bp', + name: 'British Parliamentary', + shortName: 'BP', + sideNames: ['Government', 'Opposition'], + segments: [ + speech('pm', 'PM', 'Prime Minister (Opening Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('lo', 'LO', 'Leader of the Opposition (Opening Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('dpm', 'DPM', 'Deputy Prime Minister (Opening Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('dlo', 'DLO', 'Deputy Leader of the Opposition (Opening Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('mg', 'MG', 'Member of Government (Closing Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('mo', 'MO', 'Member of Opposition (Closing Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('gw', 'GW', 'Government Whip (Closing Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('ow', 'OW', 'Opposition Whip (Closing Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + ], + prepSeconds: 0, + graceSeconds: 15, + sources: [ + { label: 'WUDC Debating & Judging Manual (2024 revision)', url: 'https://debate.ethz.ch/wp-content/uploads/2024/11/WUDC-Debating-Judging-Manual-Revised-November-2024-.pdf' }, + ], + note: 'Teams get 15 minutes of preparation after the motion is released, before the round. Signals sound at 1:00, 6:00 and 7:00; judges disregard material after 7:15.', + }, + wsdc: { + id: 'wsdc', + name: 'World Schools', + shortName: 'WSDC', + sideNames: ['Proposition', 'Opposition'], + segments: [ + speech('p1', 'Prop 1', 'First Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + speech('o1', 'Opp 1', 'First Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + speech('p2', 'Prop 2', 'Second Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + speech('o2', 'Opp 2', 'Second Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + speech('p3', 'Prop 3', 'Third Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + speech('o3', 'Opp 3', 'Third Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), + reply('or', 'Opp Reply', 'Opposition reply (first or second speaker)', 'b', 4 * MIN), + reply('pr', 'Prop Reply', 'Proposition reply (first or second speaker)', 'a', 4 * MIN), + ], + prepSeconds: 0, + sources: [ + { label: 'WSDC Tournament Committee & Debate Rules (August 2026)', url: 'https://www.wsdcdebating.org/_files/ugd/669183_8ee4b95d4bf4409a893cc22e5f1f35b9.pdf' }, + { label: 'NSDA USA World Schools Debate Invitational manual', url: 'https://docs.google.com/document/d/1w5t9sQhQGBoRQBUomjVgx-2vThAXctWDlU3oECzQ0-g' }, + ], + note: 'Reply speeches run in reverse order, Opposition first, and take no points of information. Impromptu motions come with one hour of preparation; prepared motions are released weeks ahead.', + }, + ap: { + id: 'ap', + name: 'Asian Parliamentary', + shortName: 'AP', + sideNames: ['Government', 'Opposition'], + segments: [ + speech('pm', 'PM', 'Prime Minister', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('lo', 'LO', 'Leader of the Opposition', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('dpm', 'DPM', 'Deputy Prime Minister', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('dlo', 'DLO', 'Deputy Leader of the Opposition', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('gw', 'GW', 'Government Whip', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + speech('ow', 'OW', 'Opposition Whip', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), + reply('or', 'Opp Reply', 'Opposition reply (first or second speaker)', 'b', 4 * MIN), + reply('gr', 'Gov Reply', 'Government reply (first or second speaker)', 'a', 4 * MIN), + ], + prepSeconds: 0, + graceSeconds: 15, + sources: [ + { label: 'UADC 2023 Debating and Judging Handbook', url: 'https://ugc.production.linktr.ee/e32c9614-640c-4ce1-8522-3b613a228a89_Malaysia-UADC-2023---Debate---Judging-Handbook.pdf' }, + ], + note: 'There is no single official rulebook; this follows the UADC handbook, with 30 minutes of preparation before the round. Some school leagues shorten speeches, so check your tournament.', + }, + school: { + id: 'school', + name: 'School 4-6-6-3', + shortName: '4-6-6-3', + sideNames: ['Pro', 'Con'], + segments: [ + speech('pi', 'Pro Intro', 'Pro introductor', 'a', 4 * MIN, undefined, 'introductor'), + speech('ci', 'Con Intro', 'Con introductor', 'b', 4 * MIN, undefined, 'introductor'), + speech('pr1', 'Pro R1', 'Pro first rebuttal', 'a', 6 * MIN, undefined, 'first rebuttal'), + speech('cr1', 'Con R1', 'Con first rebuttal', 'b', 6 * MIN, undefined, 'first rebuttal'), + speech('pr2', 'Pro R2', 'Pro second rebuttal', 'a', 6 * MIN, undefined, 'second rebuttal'), + speech('cr2', 'Con R2', 'Con second rebuttal', 'b', 6 * MIN, undefined, 'second rebuttal'), + speech('pc', 'Pro Concl', 'Pro conclusor', 'a', 3 * MIN, undefined, 'conclusor'), + speech('cc', 'Con Concl', 'Con conclusor', 'b', 3 * MIN, undefined, 'conclusor'), + ], + prepSeconds: 0, + sources: [], + note: 'The school format DebateOwl uses for its own sparring rounds: introductor, two rebuttals and a conclusor per side. It is not a national league format.', + }, +}; + +export const BUILT_IN_FORMATS: readonly RoundFormat[] = BUILT_IN_FORMAT_IDS.map((id) => FORMATS[id]); + +export function isBuiltInFormatId(value: string | null | undefined): value is BuiltInFormatId { + return typeof value === 'string' && (BUILT_IN_FORMAT_IDS as readonly string[]).includes(value); +} + +/** The speeches a flow sheet gets a column for and a speech can be timed against: no questioning periods. */ +export function speakingSegments(format: RoundFormat): RoundSegment[] { + return format.segments.filter((segment) => segment.kind === 'speech' || segment.kind === 'reply'); +} + +export const segmentRole = (segment: RoundSegment) => segment.role ?? segment.short; + +export function questioningSegments(format: RoundFormat): RoundSegment[] { + return format.segments.filter((segment) => segment.kind === 'cross-examination' || segment.kind === 'crossfire'); +} + +export function roundSeconds(format: RoundFormat): number { + return format.segments.reduce((total, segment) => total + segment.seconds, 0) + 2 * format.prepSeconds; +} + +/** "4:00", "7:30", "1:00:00". */ +export function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = String(seconds % 60).padStart(2, '0'); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`; +} diff --git a/packages/core/src/free-tools/motion-bank.ts b/packages/core/src/free-tools/motion-bank.ts new file mode 100644 index 000000000..42d1d8a68 --- /dev/null +++ b/packages/core/src/free-tools/motion-bank.ts @@ -0,0 +1,96 @@ +/** Filtering and random draws over the practice motion bank. */ +import { MOTION_DIFFICULTIES, MOTION_TOPICS, MOTION_TYPES, type MotionDifficulty, type MotionTopic, type MotionType, type PracticeMotion } from './motion-types'; + +export interface MotionFilters { + /** Empty means every topic; likewise for the others. */ + topics: MotionTopic[]; + difficulties: MotionDifficulty[]; + types: MotionType[]; +} + +export const NO_FILTERS: MotionFilters = { topics: [], difficulties: [], types: [] }; + +export type MotionWording = 'house' | 'resolved'; + +export const TOPIC_NAMES: Record = { + education: 'Education', + technology: 'Technology and AI', + environment: 'Environment', + economics: 'Economics', + politics: 'Politics and democracy', + health: 'Health', + sport: 'Sport', + culture: 'Culture and media', + ethics: 'Ethics', + international: 'International relations', + justice: 'Justice and law', + society: 'Society', +}; + +export function filterMotions(motions: readonly PracticeMotion[], filters: MotionFilters): PracticeMotion[] { + return motions.filter((motion) => + (filters.topics.length === 0 || filters.topics.includes(motion.topic)) && + (filters.difficulties.length === 0 || filters.difficulties.includes(motion.difficulty)) && + (filters.types.length === 0 || filters.types.includes(motion.type))); +} + +/** + * Up to `count` different motions, chosen uniformly. `exclude` keeps the ones + * just shown out of the next draw while enough others remain. + */ +export function drawMotions( + pool: readonly PracticeMotion[], + count: number, + random: () => number = Math.random, + exclude: readonly string[] = [], +): PracticeMotion[] { + const fresh = pool.filter((motion) => !exclude.includes(motion.id)); + const source = [...(fresh.length >= count ? fresh : pool)]; + const picked: PracticeMotion[] = []; + while (picked.length < count && source.length > 0) { + const index = Math.min(source.length - 1, Math.floor(random() * source.length)); + picked.push(source.splice(index, 1)[0]); + } + return picked; +} + +export function motionText(motion: PracticeMotion, wording: MotionWording): string { + return wording === 'resolved' ? motion.resolved : motion.motion; +} + +export function findMotion(motions: readonly PracticeMotion[], id: string | null | undefined): PracticeMotion | undefined { + return id ? motions.find((motion) => motion.id === id) : undefined; +} + +function listParam(value: string | null, allowed: readonly T[]): T[] { + if (!value) return []; + return [...new Set(value.split(',').filter((entry): entry is T => (allowed as readonly string[]).includes(entry)))]; +} + +/** Filters from `?topic=a,b&level=novice&type=policy`; unknown values are ignored. */ +export function filtersFromSearch(search: string): MotionFilters { + const params = new URLSearchParams(search); + return { + topics: listParam(params.get('topic'), MOTION_TOPICS), + difficulties: listParam(params.get('level'), MOTION_DIFFICULTIES), + types: listParam(params.get('type'), MOTION_TYPES), + }; +} + +/** The search string for a shown motion and the filters that drew it: `?m=edu-004&topic=education`. */ +export function searchFor(filters: MotionFilters, motionIds: readonly string[], wording: MotionWording): string { + const params = new URLSearchParams(); + if (motionIds.length > 0) params.set('m', motionIds.join(',')); + if (filters.topics.length) params.set('topic', filters.topics.join(',')); + if (filters.difficulties.length) params.set('level', filters.difficulties.join(',')); + if (filters.types.length) params.set('type', filters.types.join(',')); + if (wording === 'resolved') params.set('wording', 'resolved'); + const text = params.toString(); + return text ? `?${text}` : ''; +} + +/** The motions a permalink names, in order, skipping ids that do not exist; at most three. */ +export function motionsFromSearch(motions: readonly PracticeMotion[], search: string): PracticeMotion[] { + const ids = new URLSearchParams(search).get('m')?.split(',') ?? []; + return [...new Set(ids)].slice(0, 3).map((id) => findMotion(motions, id)).filter((motion): motion is PracticeMotion => Boolean(motion)); +} diff --git a/packages/core/src/free-tools/motion-types.ts b/packages/core/src/free-tools/motion-types.ts new file mode 100644 index 000000000..3e89a0f36 --- /dev/null +++ b/packages/core/src/free-tools/motion-types.ts @@ -0,0 +1,36 @@ +export const MOTION_TOPICS = [ + 'education', + 'technology', + 'environment', + 'economics', + 'politics', + 'health', + 'sport', + 'culture', + 'ethics', + 'international', + 'justice', + 'society', +] as const; +export type MotionTopic = (typeof MOTION_TOPICS)[number]; + +export const MOTION_DIFFICULTIES = ['novice', 'intermediate', 'advanced'] as const; +export type MotionDifficulty = (typeof MOTION_DIFFICULTIES)[number]; + +/** policy "This House would", value "This House believes that", actor "This House, as X, would", regret, prefer. */ +export const MOTION_TYPES = ['policy', 'value', 'actor', 'regret', 'prefer'] as const; +export type MotionType = (typeof MOTION_TYPES)[number]; + +export interface PracticeMotion { + /** Stable permalink id (`?m=`): topic prefix and number, never renumbered. */ + id: string; + /** BP and World Schools wording: "This House would ...". */ + motion: string; + /** US-format wording: "Resolved: ...". */ + resolved: string; + topic: MotionTopic; + difficulty: MotionDifficulty; + type: MotionType; + /** Optional one-sentence context for an unfamiliar term; plain English. */ + infoSlide?: string; +} diff --git a/packages/core/src/free-tools/practice-motions.ts b/packages/core/src/free-tools/practice-motions.ts new file mode 100644 index 000000000..6adb8f547 --- /dev/null +++ b/packages/core/src/free-tools/practice-motions.ts @@ -0,0 +1,2436 @@ +import type { PracticeMotion } from './motion-types'; + +/** Original practice motions for the free motion generator. Ids are permalinks: never renumber or reuse them. */ +export const PRACTICE_MOTIONS: readonly PracticeMotion[] = [ + { + id: 'edu-001', + motion: 'This House would replace homework with extra reading time at school', + resolved: 'Resolved: Schools should replace homework with extra reading time during the school day.', + topic: 'education', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'edu-002', + motion: 'This House would let students grade their teachers at the end of every term', + resolved: 'Resolved: Students should grade their teachers at the end of every term.', + topic: 'education', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'edu-003', + motion: 'This House believes that school trips teach students as much as classroom lessons', + resolved: 'Resolved: School trips teach students as much as classroom lessons.', + topic: 'education', + difficulty: 'novice', + type: 'value', + }, + { + id: 'edu-004', + motion: 'This House, as a school principal, would let students choose where they sit in every class', + resolved: 'Resolved: School principals should let students choose where they sit in every class.', + topic: 'education', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'edu-005', + motion: 'This House regrets the tradition of end-of-year awards ceremonies in schools', + resolved: 'Resolved: End-of-year awards ceremonies in schools have done more harm than good.', + topic: 'education', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'edu-006', + motion: 'This House would make students lock their phones in sealed pouches for the whole school day', + resolved: 'Resolved: Students should be required to lock their phones in sealed pouches for the whole school day.', + topic: 'education', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'edu-007', + motion: 'This House would make cooking a required subject in every secondary school', + resolved: 'Resolved: Cooking should be a required subject in every secondary school.', + topic: 'education', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'edu-008', + motion: 'This House prefers a four-day school week with longer days to a five-day week with shorter days', + resolved: 'Resolved: A world in which schools run a four-day week with longer days is preferable to one in which they run a five-day week with shorter days.', + topic: 'education', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'edu-009', + motion: 'This House would replace letter grades with written feedback for all students under 16', + resolved: 'Resolved: Schools should replace letter grades with written feedback for all students under 16.', + topic: 'education', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'edu-010', + motion: 'This House would require every student to complete a work placement before leaving secondary school', + resolved: 'Resolved: Every student should complete a work placement before leaving secondary school.', + topic: 'education', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'edu-011', + motion: 'This House believes that financial literacy matters more than algebra for most students', + resolved: 'Resolved: Financial literacy matters more than algebra for most students.', + topic: 'education', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'edu-012', + motion: 'This House believes that schools should stay neutral on contested political issues', + resolved: 'Resolved: Schools ought to stay neutral on contested political issues.', + topic: 'education', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'edu-013', + motion: 'This House, as a selective university, would admit students by lottery from among all qualified applicants', + resolved: 'Resolved: Selective universities should admit students by lottery from among all qualified applicants.', + topic: 'education', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'edu-014', + motion: 'This House regrets school apps that show parents every grade the moment it is entered', + resolved: 'Resolved: School apps that show parents every grade the moment it is entered have done more harm than good.', + topic: 'education', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'edu-015', + motion: 'This House would pay students a small weekly sum for full attendance', + resolved: 'Resolved: Schools should pay students a small weekly sum for full attendance.', + topic: 'education', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'edu-016', + motion: 'This House prefers mixed-ability classes to classes sorted by ability', + resolved: 'Resolved: A world in which students learn in mixed-ability classes is preferable to one in which they are sorted into classes by ability.', + topic: 'education', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'edu-017', + motion: 'This House would replace university tuition fees with a graduate tax', + resolved: 'Resolved: University tuition fees should be replaced with a graduate tax.', + topic: 'education', + difficulty: 'intermediate', + type: 'policy', + infoSlide: 'A graduate tax is an extra tax paid only by people who went to university, charged as a share of their income after they graduate.', + }, + { + id: 'edu-018', + motion: 'This House would require elite universities to reserve half their places for applicants from the lowest-performing schools', + resolved: 'Resolved: Elite universities should reserve half their places for applicants from the lowest-performing schools.', + topic: 'education', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'edu-019', + motion: 'This House believes that the main purpose of education is to make good citizens rather than skilled workers', + resolved: 'Resolved: The main purpose of education ought to be making good citizens rather than skilled workers.', + topic: 'education', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'edu-020', + motion: 'This House, as a national education ministry, would fund schools by how much their pupils improve rather than by how many pupils they have', + resolved: 'Resolved: National education ministries should fund schools by how much their pupils improve rather than by how many pupils they have.', + topic: 'education', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'edu-021', + motion: 'This House regrets the global spread of university rankings', + resolved: 'Resolved: The global spread of university rankings has done more harm than good.', + topic: 'education', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'edu-022', + motion: 'This House believes that parents should be able to withdraw their children from any lesson that conflicts with their values', + resolved: 'Resolved: Parents ought to be able to withdraw their children from any lesson that conflicts with their values.', + topic: 'education', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'edu-023', + motion: 'This House believes that private schools are incompatible with a fair society', + resolved: 'Resolved: Private schools are incompatible with a fair society.', + topic: 'education', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'edu-024', + motion: 'This House, as a teachers\' union, would accept performance-related pay in exchange for higher base salaries', + resolved: 'Resolved: Teachers\' unions should accept performance-related pay in exchange for higher base salaries.', + topic: 'education', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'edu-025', + motion: 'This House would scrap high-stakes final exams at 18 in favor of assessment by students\' own teachers', + resolved: 'Resolved: High-stakes final exams at 18 should be scrapped in favor of assessment by students\' own teachers.', + topic: 'education', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'tech-001', + motion: 'This House would require every app to show users how many hours they spent on it each week', + resolved: 'Resolved: Every app should be required to show users how many hours they spent on it each week.', + topic: 'technology', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'tech-002', + motion: 'This House would keep smart speakers out of children\'s bedrooms', + resolved: 'Resolved: Smart speakers should be kept out of children\'s bedrooms.', + topic: 'technology', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'tech-003', + motion: 'This House believes that AI chatbots should always tell people they are not human', + resolved: 'Resolved: AI chatbots ought to always tell people that they are not human.', + topic: 'technology', + difficulty: 'novice', + type: 'value', + }, + { + id: 'tech-004', + motion: 'This House, as a parent, would track their teenager\'s location through their phone', + resolved: 'Resolved: Parents should track their teenagers\' locations through their phones.', + topic: 'technology', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'tech-005', + motion: 'This House prefers learning from a human tutor to learning from an AI tutor', + resolved: 'Resolved: A world in which students learn from human tutors is preferable to one in which they learn from AI tutors.', + topic: 'technology', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'tech-006', + motion: 'This House would allow delivery robots on city sidewalks', + resolved: 'Resolved: Delivery robots should be allowed on city sidewalks.', + topic: 'technology', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'tech-007', + motion: 'This House believes that it is wrong to use AI to write a personal message like a birthday card', + resolved: 'Resolved: It is wrong to use AI to write a personal message such as a birthday card.', + topic: 'technology', + difficulty: 'novice', + type: 'value', + }, + { + id: 'tech-008', + motion: 'This House would require every new phone to be repairable at home with ordinary tools', + resolved: 'Resolved: Every new phone should be repairable at home with ordinary tools.', + topic: 'technology', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'tech-009', + motion: 'This House would require all AI-generated images to carry a visible label', + resolved: 'Resolved: All AI-generated images should carry a visible label.', + topic: 'technology', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'tech-010', + motion: 'This House would ban shops from using facial recognition to identify customers', + resolved: 'Resolved: Shops should be banned from using facial recognition to identify customers.', + topic: 'technology', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'tech-011', + motion: 'This House believes that tech companies should be legally responsible for harm caused by their recommendation algorithms', + resolved: 'Resolved: Tech companies ought to be legally responsible for harm caused by their recommendation algorithms.', + topic: 'technology', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'tech-012', + motion: 'This House, as a leading AI company, would delay every new model until independent safety testers approve it', + resolved: 'Resolved: Leading AI companies should delay every new model until independent safety testers approve it.', + topic: 'technology', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'tech-013', + motion: 'This House regrets the replacement of human customer service staff with chatbots', + resolved: 'Resolved: The replacement of human customer service staff with chatbots has done more harm than good.', + topic: 'technology', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'tech-014', + motion: 'This House would give people legal ownership of their personal data, including the right to sell it', + resolved: 'Resolved: People should legally own their personal data, including the right to sell it.', + topic: 'technology', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'tech-015', + motion: 'This House prefers an internet where people post under their real names to one where they can stay anonymous', + resolved: 'Resolved: A world in which internet users post under their real names is preferable to one in which they can stay anonymous.', + topic: 'technology', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'tech-016', + motion: 'This House would tax companies for every job they replace with automation', + resolved: 'Resolved: Companies should pay a tax for every job they replace with automation.', + topic: 'technology', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'tech-017', + motion: 'This House believes that AI should never be used to grade student essays', + resolved: 'Resolved: AI ought never to be used to grade student essays.', + topic: 'technology', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'tech-018', + motion: 'This House would grant advanced AI systems legal personhood for the purposes of liability', + resolved: 'Resolved: Advanced AI systems should be granted legal personhood for the purposes of liability.', + topic: 'technology', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'Legal personhood means the law treats something as a person that can own property, sign contracts, and be sued, as it already does for companies.', + }, + { + id: 'tech-019', + motion: 'This House believes that end-to-end encryption should be protected even when it shields criminals', + resolved: 'Resolved: End-to-end encryption ought to be protected even when it shields criminals.', + topic: 'technology', + difficulty: 'advanced', + type: 'value', + infoSlide: 'End-to-end encryption scrambles messages so that only the sender and receiver can read them, not even the company running the service.', + }, + { + id: 'tech-020', + motion: 'This House, as a national government, would build its own public AI model rather than rely on private companies', + resolved: 'Resolved: National governments should build their own public AI models rather than rely on private companies.', + topic: 'technology', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'tech-021', + motion: 'This House regrets the concentration of cutting-edge AI development in a handful of private companies', + resolved: 'Resolved: The concentration of cutting-edge AI development in a handful of private companies has done more harm than good.', + topic: 'technology', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'tech-022', + motion: 'This House would require AI models trained on publicly available data to be released as open source', + resolved: 'Resolved: AI models trained on publicly available data should be released as open source.', + topic: 'technology', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'Open source means the code or model is published so that anyone can inspect, use, and change it.', + }, + { + id: 'tech-023', + motion: 'This House believes that humanity should deliberately slow down the development of advanced AI', + resolved: 'Resolved: Humanity ought to deliberately slow down the development of advanced AI.', + topic: 'technology', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'tech-024', + motion: 'This House, as a space agency, would prioritize robotic missions over sending humans to Mars', + resolved: 'Resolved: Space agencies should prioritize robotic missions over sending humans to Mars.', + topic: 'technology', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'tech-025', + motion: 'This House prefers AI that only advises human decision-makers to AI that makes high-stakes decisions on its own', + resolved: 'Resolved: A world in which AI only advises human decision-makers is preferable to one in which AI makes high-stakes decisions on its own.', + topic: 'technology', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'env-001', + motion: 'This House would put a refundable deposit on every drink can and bottle', + resolved: 'Resolved: Every drink can and bottle should carry a refundable deposit.', + topic: 'environment', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'env-002', + motion: 'This House would close the streets outside schools to cars at drop-off and pick-up times', + resolved: 'Resolved: Streets outside schools should be closed to cars at drop-off and pick-up times.', + topic: 'environment', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'env-003', + motion: 'This House believes that grass lawns are a waste of space', + resolved: 'Resolved: Grass lawns are a waste of space.', + topic: 'environment', + difficulty: 'novice', + type: 'value', + }, + { + id: 'env-004', + motion: 'This House, as a city mayor, would turn half of the city\'s parking spaces into parks', + resolved: 'Resolved: City mayors should turn half of their city\'s parking spaces into parks.', + topic: 'environment', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'env-005', + motion: 'This House regrets the rise of ultra-cheap clothing sold online', + resolved: 'Resolved: The rise of ultra-cheap clothing sold online has done more harm than good.', + topic: 'environment', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'env-006', + motion: 'This House would charge households for trash collection by the weight of what they throw away', + resolved: 'Resolved: Households should pay for trash collection by the weight of what they throw away.', + topic: 'environment', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'env-007', + motion: 'This House believes that keeping pets is bad for the planet', + resolved: 'Resolved: Keeping pets is bad for the planet.', + topic: 'environment', + difficulty: 'novice', + type: 'value', + }, + { + id: 'env-008', + motion: 'This House prefers buying local food to buying the cheapest food available', + resolved: 'Resolved: A world in which people buy local food is preferable to one in which they buy the cheapest food available.', + topic: 'environment', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'env-009', + motion: 'This House would tax meat according to its carbon footprint', + resolved: 'Resolved: Meat should be taxed according to its carbon footprint.', + topic: 'environment', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'env-010', + motion: 'This House would ban flights on routes where the same trip takes under five hours by train', + resolved: 'Resolved: Flights should be banned on routes where the same trip takes under five hours by train.', + topic: 'environment', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'env-011', + motion: 'This House believes that nuclear power is essential to fighting climate change', + resolved: 'Resolved: Nuclear power is essential to fighting climate change.', + topic: 'environment', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'env-012', + motion: 'This House, as an environmental group, would use disruptive protests such as blocking roads', + resolved: 'Resolved: Environmental groups should use disruptive protests such as blocking roads.', + topic: 'environment', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'env-013', + motion: 'This House regrets the rise of carbon offsetting', + resolved: 'Resolved: The rise of carbon offsetting has done more harm than good.', + topic: 'environment', + difficulty: 'intermediate', + type: 'regret', + infoSlide: 'Carbon offsetting means paying for a project, such as planting trees, that is meant to cancel out the greenhouse gases you produced.', + }, + { + id: 'env-014', + motion: 'This House would let citizens sue their government for missing its own climate targets', + resolved: 'Resolved: Citizens should be able to sue their government for missing its own climate targets.', + topic: 'environment', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'env-015', + motion: 'This House would require solar panels on every new home', + resolved: 'Resolved: Every new home should be required to have solar panels.', + topic: 'environment', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'env-016', + motion: 'This House believes that pressure on individuals to live green distracts from holding big polluters to account', + resolved: 'Resolved: Pressure on individuals to live green distracts from holding big polluters to account.', + topic: 'environment', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'env-017', + motion: 'This House would pay farmers to rewild a fifth of their land', + resolved: 'Resolved: Farmers should be paid to rewild a fifth of their land.', + topic: 'environment', + difficulty: 'intermediate', + type: 'policy', + infoSlide: 'Rewilding means letting land return to a natural state, often by bringing back native plants and animals.', + }, + { + id: 'env-018', + motion: 'This House would impose a carbon border tax on imported goods', + resolved: 'Resolved: Countries should impose a carbon border tax on imported goods.', + topic: 'environment', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'A carbon border tax is a charge on imported goods based on how much greenhouse gas was released in making them.', + }, + { + id: 'env-019', + motion: 'This House believes that rich countries should pursue degrowth', + resolved: 'Resolved: Rich countries ought to pursue degrowth.', + topic: 'environment', + difficulty: 'advanced', + type: 'value', + infoSlide: 'Degrowth is the idea that rich countries should deliberately shrink production and consumption to protect the environment.', + }, + { + id: 'env-020', + motion: 'This House, as a developing country, would refuse further emissions cuts until wealthy countries pay climate compensation', + resolved: 'Resolved: Developing countries should refuse further emissions cuts until wealthy countries pay climate compensation.', + topic: 'environment', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'env-021', + motion: 'This House regrets the framing of climate change as an emergency', + resolved: 'Resolved: Framing climate change as an emergency has done more harm than good.', + topic: 'environment', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'env-022', + motion: 'This House would fund large-scale trials of solar geoengineering', + resolved: 'Resolved: Governments should fund large-scale trials of solar geoengineering.', + topic: 'environment', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'Solar geoengineering means deliberately reflecting some sunlight away from Earth, for example with particles released high in the atmosphere, to cool the planet.', + }, + { + id: 'env-023', + motion: 'This House believes that the interests of future generations should count as much as those of people alive today', + resolved: 'Resolved: The interests of future generations ought to count as much as those of people alive today.', + topic: 'environment', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'env-024', + motion: 'This House, as a major oil-producing country, would leave its reserves in the ground in exchange for international payments', + resolved: 'Resolved: Major oil-producing countries should leave their reserves in the ground in exchange for international payments.', + topic: 'environment', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'env-025', + motion: 'This House prefers protecting a few large wilderness areas to protecting many small green spaces near where people live', + resolved: 'Resolved: A world in which conservation protects a few large wilderness areas is preferable to one in which it protects many small green spaces near where people live.', + topic: 'environment', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'econ-001', + motion: 'This House would let children under 16 earn money from part-time jobs only with school approval', + resolved: 'Resolved: Children under 16 should be allowed to earn money from part-time jobs only with school approval.', + topic: 'economics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'econ-002', + motion: 'This House would require supermarkets to give unsold food to charities instead of throwing it away', + resolved: 'Resolved: Supermarkets should be required to give unsold food to charities instead of throwing it away.', + topic: 'economics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'econ-003', + motion: 'This House believes that children should earn their allowance by doing chores', + resolved: 'Resolved: Children ought to earn their allowance by doing chores.', + topic: 'economics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'econ-004', + motion: 'This House, as a small business owner, would pay all staff the same wage regardless of their role', + resolved: 'Resolved: Small business owners should pay all staff the same wage regardless of their role.', + topic: 'economics', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'econ-005', + motion: 'This House prefers a world where people mostly pay with cash to one where people mostly pay by card or phone', + resolved: 'Resolved: A world in which people mostly pay with cash is preferable to one in which they mostly pay by card or phone.', + topic: 'economics', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'econ-006', + motion: 'This House would ban buy-now-pay-later offers for shoppers under 21', + resolved: 'Resolved: Buy-now-pay-later offers should be banned for shoppers under 21.', + topic: 'economics', + difficulty: 'novice', + type: 'policy', + infoSlide: 'Buy-now-pay-later lets shoppers take a product today and pay for it in installments later, often without a full credit check.', + }, + { + id: 'econ-007', + motion: 'This House believes that tipping in restaurants should be replaced by higher wages', + resolved: 'Resolved: Tipping in restaurants ought to be replaced by higher wages.', + topic: 'economics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'econ-008', + motion: 'This House regrets the rise of subscriptions for things people used to buy once', + resolved: 'Resolved: The rise of subscriptions for things people used to buy once has done more harm than good.', + topic: 'economics', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'econ-009', + motion: 'This House would introduce a four-day working week with no cut in pay', + resolved: 'Resolved: Employers should introduce a four-day working week with no cut in pay.', + topic: 'economics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'econ-010', + motion: 'This House would require app-based delivery and ride companies to treat their drivers as employees', + resolved: 'Resolved: App-based delivery and ride companies should be required to treat their drivers as employees.', + topic: 'economics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'econ-011', + motion: 'This House believes that inheritance is unfair', + resolved: 'Resolved: Inheritance is unfair.', + topic: 'economics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'econ-012', + motion: 'This House, as a city council, would cap rent increases on all private housing', + resolved: 'Resolved: City councils should cap rent increases on all private housing.', + topic: 'economics', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'econ-013', + motion: 'This House would tax large online retailers to fund struggling local shopping streets', + resolved: 'Resolved: Large online retailers should be taxed to fund struggling local shopping streets.', + topic: 'economics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'econ-014', + motion: 'This House would cap the pay of company bosses at twenty times that of their lowest-paid worker', + resolved: 'Resolved: The pay of company bosses should be capped at twenty times that of their lowest-paid worker.', + topic: 'economics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'econ-015', + motion: 'This House prefers a society that taxes wealth to one that taxes income', + resolved: 'Resolved: A world in which governments tax wealth is preferable to one in which they tax income.', + topic: 'economics', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'econ-016', + motion: 'This House would give every citizen a lump sum of money on their 18th birthday', + resolved: 'Resolved: Every citizen should receive a lump sum of money on their 18th birthday.', + topic: 'economics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'econ-017', + motion: 'This House believes that billionaires are a sign of a failing economy', + resolved: 'Resolved: The existence of billionaires is a sign of a failing economy.', + topic: 'economics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'econ-018', + motion: 'This House would replace property taxes with a tax on the value of land alone', + resolved: 'Resolved: Property taxes should be replaced with a tax on the value of land alone.', + topic: 'economics', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'A land value tax charges owners based on the value of the land itself, not on any buildings on it.', + }, + { + id: 'econ-019', + motion: 'This House would give workers half of the seats on the boards of large companies', + resolved: 'Resolved: Workers should hold half of the seats on the boards of large companies.', + topic: 'economics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'econ-020', + motion: 'This House, as a central bank, would launch a digital currency that citizens hold directly with it', + resolved: 'Resolved: Central banks should launch digital currencies that citizens hold directly with them.', + topic: 'economics', + difficulty: 'advanced', + type: 'actor', + infoSlide: 'A central bank manages a country\'s money supply and interest rates; a central bank digital currency would be electronic cash issued by it directly.', + }, + { + id: 'econ-021', + motion: 'This House regrets the independence of central banks from elected governments', + resolved: 'Resolved: The independence of central banks from elected governments has done more harm than good.', + topic: 'economics', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'econ-022', + motion: 'This House believes that economic growth should no longer be the main goal of government policy', + resolved: 'Resolved: Economic growth ought no longer to be the main goal of government policy.', + topic: 'economics', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'econ-023', + motion: 'This House, as a developing country, would protect its young industries with high tariffs', + resolved: 'Resolved: Developing countries should protect their young industries with high tariffs.', + topic: 'economics', + difficulty: 'advanced', + type: 'actor', + infoSlide: 'A tariff is a tax on goods brought in from other countries.', + }, + { + id: 'econ-024', + motion: 'This House believes that companies should serve all their stakeholders, not just their shareholders', + resolved: 'Resolved: Companies ought to serve all their stakeholders, not just their shareholders.', + topic: 'economics', + difficulty: 'advanced', + type: 'value', + infoSlide: 'Stakeholders are everyone affected by a company, such as workers, customers, and local communities, while shareholders are the people who own it.', + }, + { + id: 'econ-025', + motion: 'This House prefers a jobs guarantee to unconditional cash payments', + resolved: 'Resolved: A world in which the government guarantees everyone a job is preferable to one in which it gives everyone unconditional cash payments.', + topic: 'economics', + difficulty: 'advanced', + type: 'prefer', + infoSlide: 'A jobs guarantee means the government promises paid work to anyone who wants it but cannot find a job.', + }, + { + id: 'pol-001', + motion: 'This House would let 16-year-olds vote in local elections but not national ones', + resolved: 'Resolved: Sixteen-year-olds should be able to vote in local elections but not national ones.', + topic: 'politics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'pol-002', + motion: 'This House would make election day a public holiday', + resolved: 'Resolved: Election day should be a public holiday.', + topic: 'politics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'pol-003', + motion: 'This House believes that politicians should be required to have had a job outside politics', + resolved: 'Resolved: Politicians ought to be required to have had a job outside politics.', + topic: 'politics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'pol-004', + motion: 'This House, as a student council, would hold a whole-school vote before every major decision', + resolved: 'Resolved: Student councils should hold a whole-school vote before every major decision.', + topic: 'politics', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'pol-005', + motion: 'This House regrets the use of attack ads in election campaigns', + resolved: 'Resolved: The use of attack ads in election campaigns has done more harm than good.', + topic: 'politics', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'pol-006', + motion: 'This House would let people vote from their phones', + resolved: 'Resolved: People should be able to vote from their phones.', + topic: 'politics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'pol-007', + motion: 'This House believes that no politician should serve more than two terms in the same office', + resolved: 'Resolved: No politician ought to serve more than two terms in the same office.', + topic: 'politics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'pol-008', + motion: 'This House prefers leaders chosen for their experience to leaders chosen for their fresh ideas', + resolved: 'Resolved: A world in which voters choose leaders for their experience is preferable to one in which they choose leaders for their fresh ideas.', + topic: 'politics', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'pol-009', + motion: 'This House would make voting compulsory', + resolved: 'Resolved: Voting should be compulsory.', + topic: 'politics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'pol-010', + motion: 'This House would let citizens recall their elected representatives through a petition', + resolved: 'Resolved: Citizens should be able to recall their elected representatives through a petition.', + topic: 'politics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'pol-011', + motion: 'This House believes that referendums do more to damage democracy than strengthen it', + resolved: 'Resolved: Referendums do more to damage democracy than to strengthen it.', + topic: 'politics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'pol-012', + motion: 'This House, as a newly elected mayor, would let residents decide directly how a tenth of the city budget is spent', + resolved: 'Resolved: Newly elected mayors should let residents decide directly how a tenth of the city budget is spent.', + topic: 'politics', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'pol-013', + motion: 'This House regrets the rise of celebrity politicians', + resolved: 'Resolved: The rise of celebrity politicians has done more harm than good.', + topic: 'politics', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'pol-014', + motion: 'This House would ban private donations to political parties and fund them from public money', + resolved: 'Resolved: Private donations to political parties should be banned and parties funded from public money.', + topic: 'politics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'pol-015', + motion: 'This House would set aside seats in parliament for representatives under 30', + resolved: 'Resolved: Seats in national legislatures should be set aside for representatives under 30.', + topic: 'politics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'pol-016', + motion: 'This House believes that politicians should vote according to their conscience rather than what their voters want', + resolved: 'Resolved: Politicians ought to vote according to their conscience rather than what their voters want.', + topic: 'politics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'pol-017', + motion: 'This House prefers coalition governments to single-party governments', + resolved: 'Resolved: A world in which countries are run by coalition governments is preferable to one in which they are run by single-party governments.', + topic: 'politics', + difficulty: 'intermediate', + type: 'prefer', + infoSlide: 'A coalition government is formed when two or more parties agree to govern together because none has a majority alone.', + }, + { + id: 'pol-018', + motion: 'This House would replace an elected upper chamber with an assembly of randomly selected citizens', + resolved: 'Resolved: An elected upper chamber should be replaced with an assembly of randomly selected citizens.', + topic: 'politics', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'An upper chamber is the second house of a parliament, such as a senate, which reviews laws passed by the main house.', + }, + { + id: 'pol-019', + motion: 'This House believes that democracy is best protected by limiting what a simple majority can change', + resolved: 'Resolved: Democracy is best protected by limiting what a simple majority can change.', + topic: 'politics', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'pol-020', + motion: 'This House, as an opposition party, would cooperate with the government on major national issues rather than oppose it', + resolved: 'Resolved: Opposition parties should cooperate with the government on major national issues rather than oppose it.', + topic: 'politics', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'pol-021', + motion: 'This House regrets the rise of single-issue political movements', + resolved: 'Resolved: The rise of single-issue political movements has done more harm than good.', + topic: 'politics', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'pol-022', + motion: 'This House would give parents an extra vote for each child below voting age', + resolved: 'Resolved: Parents should receive an extra vote for each child below voting age.', + topic: 'politics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'pol-023', + motion: 'This House believes that the state should actively promote a shared national identity', + resolved: 'Resolved: The state ought to actively promote a shared national identity.', + topic: 'politics', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'pol-024', + motion: 'This House, as a regional government seeking independence, would hold a referendum without the national government\'s consent', + resolved: 'Resolved: Regional governments seeking independence should hold referendums without the national government\'s consent.', + topic: 'politics', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'pol-025', + motion: 'This House would let independent experts, not elected ministers, set national budget targets', + resolved: 'Resolved: Independent experts, not elected ministers, should set national budget targets.', + topic: 'politics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'health-001', + motion: 'This House would ban energy drinks for anyone under 16', + resolved: 'Resolved: Energy drinks should be banned for anyone under 16.', + topic: 'health', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'health-002', + motion: 'This House would start the school day no earlier than 9:30 to let teenagers sleep', + resolved: 'Resolved: The school day should start no earlier than 9:30 to let teenagers sleep.', + topic: 'health', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'health-003', + motion: 'This House believes that fitness trackers do more to stress people than to help them', + resolved: 'Resolved: Fitness trackers do more to stress people than to help them.', + topic: 'health', + difficulty: 'novice', + type: 'value', + }, + { + id: 'health-004', + motion: 'This House, as a school cafeteria, would serve only vegetarian meals one week in every month', + resolved: 'Resolved: School cafeterias should serve only vegetarian meals one week in every month.', + topic: 'health', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'health-005', + motion: 'This House prefers team sports to individual exercise as the main part of school PE', + resolved: 'Resolved: A world in which team sports are the main part of school PE is preferable to one in which individual exercise is.', + topic: 'health', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'health-006', + motion: 'This House would put warning labels on sugary snacks like those on cigarettes', + resolved: 'Resolved: Sugary snacks should carry warning labels like those on cigarettes.', + topic: 'health', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'health-007', + motion: 'This House would give every student a mental health day off each term', + resolved: 'Resolved: Every student should get a mental health day off each term.', + topic: 'health', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'health-008', + motion: 'This House believes that influencers who give diet advice should be held to the same standards as qualified nutritionists', + resolved: 'Resolved: Influencers who give diet advice ought to be held to the same standards as qualified nutritionists.', + topic: 'health', + difficulty: 'novice', + type: 'value', + }, + { + id: 'health-009', + motion: 'This House would make organ donation opt-out rather than opt-in', + resolved: 'Resolved: Organ donation should be opt-out rather than opt-in.', + topic: 'health', + difficulty: 'intermediate', + type: 'policy', + infoSlide: 'In an opt-out system everyone is treated as a willing organ donor after death unless they register a refusal.', + }, + { + id: 'health-010', + motion: 'This House would ban advertising of junk food anywhere children are likely to see it', + resolved: 'Resolved: Junk food advertising should be banned anywhere children are likely to see it.', + topic: 'health', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'health-011', + motion: 'This House believes that people who choose unhealthy lifestyles should pay more for their healthcare', + resolved: 'Resolved: People who choose unhealthy lifestyles ought to pay more for their healthcare.', + topic: 'health', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'health-012', + motion: 'This House, as a large employer, would offer staff cash rewards for meeting health goals', + resolved: 'Resolved: Large employers should offer staff cash rewards for meeting health goals.', + topic: 'health', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'health-013', + motion: 'This House regrets the use of AI symptom checkers in place of a first visit to a doctor', + resolved: 'Resolved: The use of AI symptom checkers in place of a first visit to a doctor has done more harm than good.', + topic: 'health', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'health-014', + motion: 'This House would raise the legal age for buying tobacco by one year every year', + resolved: 'Resolved: The legal age for buying tobacco should rise by one year every year.', + topic: 'health', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'health-015', + motion: 'This House prefers spending health budgets on prevention to spending them on treatment', + resolved: 'Resolved: A world in which health budgets are spent mainly on prevention is preferable to one in which they are spent mainly on treatment.', + topic: 'health', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'health-016', + motion: 'This House would let pharmacists prescribe common medicines without a doctor\'s appointment', + resolved: 'Resolved: Pharmacists should be able to prescribe common medicines without a doctor\'s appointment.', + topic: 'health', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'health-017', + motion: 'This House believes that wellness culture does more harm than good to young people', + resolved: 'Resolved: Wellness culture does more harm than good to young people.', + topic: 'health', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'health-018', + motion: 'This House would let governments override drug patents during public health emergencies', + resolved: 'Resolved: Governments should be able to override drug patents during public health emergencies.', + topic: 'health', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'A drug patent gives the company that invented a medicine the sole right to make and sell it for a number of years.', + }, + { + id: 'health-019', + motion: 'This House would pay people to donate a kidney through a regulated public scheme', + resolved: 'Resolved: People should be paid to donate a kidney through a regulated public scheme.', + topic: 'health', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'health-020', + motion: 'This House, as a public health system, would ration expensive treatments using cost-per-year-of-life thresholds', + resolved: 'Resolved: Public health systems should ration expensive treatments using cost-per-year-of-life thresholds.', + topic: 'health', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'health-021', + motion: 'This House regrets the medicalization of ordinary sadness', + resolved: 'Resolved: The medicalization of ordinary sadness has done more harm than good.', + topic: 'health', + difficulty: 'advanced', + type: 'regret', + infoSlide: 'Medicalization means treating a normal human experience as a medical condition that needs diagnosis or treatment.', + }, + { + id: 'health-022', + motion: 'This House believes that individual liberty should give way to public health in a pandemic', + resolved: 'Resolved: Individual liberty ought to give way to public health in a pandemic.', + topic: 'health', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'health-023', + motion: 'This House believes that doctors should be free to refuse treatments that conflict with their personal beliefs', + resolved: 'Resolved: Doctors ought to be free to refuse treatments that conflict with their personal beliefs.', + topic: 'health', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'health-024', + motion: 'This House, as a pharmaceutical company, would price medicines according to each country\'s wealth', + resolved: 'Resolved: Pharmaceutical companies should price medicines according to each country\'s wealth.', + topic: 'health', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'health-025', + motion: 'This House prefers a single public health system to a system of competing private insurers', + resolved: 'Resolved: A world in which healthcare is run by a single public system is preferable to one in which it is run by competing private insurers.', + topic: 'health', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'sport-001', + motion: 'This House would stop keeping score in sports matches for children under 10', + resolved: 'Resolved: Sports matches for children under 10 should not keep score.', + topic: 'sport', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'sport-002', + motion: 'This House would let girls and boys play on the same school sports teams', + resolved: 'Resolved: Girls and boys should play on the same school sports teams.', + topic: 'sport', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'sport-003', + motion: 'This House believes that esports should count as real sports', + resolved: 'Resolved: Esports should count as real sports.', + topic: 'sport', + difficulty: 'novice', + type: 'value', + infoSlide: 'Esports are organized, competitive video game tournaments, often played by professional teams.', + }, + { + id: 'sport-004', + motion: 'This House, as a youth sports coach, would give every player equal playing time', + resolved: 'Resolved: Youth sports coaches should give every player equal playing time.', + topic: 'sport', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'sport-005', + motion: 'This House regrets the use of video replay to review referee decisions', + resolved: 'Resolved: The use of video replay to review referee decisions has done more harm than good.', + topic: 'sport', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'sport-006', + motion: 'This House would ban parents from shouting at youth sports games', + resolved: 'Resolved: Parents should be banned from shouting at youth sports games.', + topic: 'sport', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'sport-007', + motion: 'This House believes that winning matters too much in school sport', + resolved: 'Resolved: Winning matters too much in school sport.', + topic: 'sport', + difficulty: 'novice', + type: 'value', + }, + { + id: 'sport-008', + motion: 'This House prefers watching sport in person to watching it on screen', + resolved: 'Resolved: A world in which fans watch sport in person is preferable to one in which they watch it on screen.', + topic: 'sport', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'sport-009', + motion: 'This House would cap the amount any club can spend on player wages', + resolved: 'Resolved: Professional sports clubs should have a cap on how much they can spend on player wages.', + topic: 'sport', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'sport-010', + motion: 'This House would ban heading the ball in youth soccer', + resolved: 'Resolved: Heading the ball should be banned in youth soccer.', + topic: 'sport', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'sport-011', + motion: 'This House believes that athletes have a duty to speak out on social issues', + resolved: 'Resolved: Athletes have a duty to speak out on social issues.', + topic: 'sport', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'sport-012', + motion: 'This House, as a professional sports club, would hand majority ownership to its fans', + resolved: 'Resolved: Professional sports clubs should hand majority ownership to their fans.', + topic: 'sport', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'sport-013', + motion: 'This House regrets the rise of sports betting advertising', + resolved: 'Resolved: The rise of sports betting advertising has done more harm than good.', + topic: 'sport', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'sport-014', + motion: 'This House would require professional leagues to share television money equally between all their clubs', + resolved: 'Resolved: Professional leagues should share television money equally between all their clubs.', + topic: 'sport', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'sport-015', + motion: 'This House would require that major sporting events be shown on free-to-air television', + resolved: 'Resolved: Major sporting events should be shown on free-to-air television.', + topic: 'sport', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'sport-016', + motion: 'This House believes that governments should not spend public money on stadiums for professional teams', + resolved: 'Resolved: Governments ought not to spend public money on stadiums for professional teams.', + topic: 'sport', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'sport-017', + motion: 'This House prefers sports leagues with promotion and relegation to closed leagues with fixed members', + resolved: 'Resolved: A world in which sports leagues use promotion and relegation is preferable to one in which they are closed leagues with fixed members.', + topic: 'sport', + difficulty: 'intermediate', + type: 'prefer', + infoSlide: 'Promotion and relegation means the best teams in a lower league move up and the worst teams in a higher league move down each season.', + }, + { + id: 'sport-018', + motion: 'This House would allow performance-enhancing drugs in professional sport under medical supervision', + resolved: 'Resolved: Performance-enhancing drugs should be allowed in professional sport under medical supervision.', + topic: 'sport', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'sport-019', + motion: 'This House believes that it is legitimate for athletes to switch national teams for better opportunities', + resolved: 'Resolved: It is legitimate for athletes to switch national teams for better opportunities.', + topic: 'sport', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'sport-020', + motion: 'This House, as the International Olympic Committee, would hold every Summer Games in one permanent host city', + resolved: 'Resolved: The International Olympic Committee should hold every Summer Games in one permanent host city.', + topic: 'sport', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'sport-021', + motion: 'This House regrets the use of data analytics to make decisions in professional sport', + resolved: 'Resolved: The use of data analytics to make decisions in professional sport has done more harm than good.', + topic: 'sport', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'sport-022', + motion: 'This House would allow athletes with prosthetic or technological enhancements to compete against everyone else', + resolved: 'Resolved: Athletes with prosthetic or technological enhancements should compete against everyone else.', + topic: 'sport', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'sport-023', + motion: 'This House believes that hosting a major sporting event is rarely worth the cost to a country', + resolved: 'Resolved: Hosting a major sporting event is rarely worth the cost to a country.', + topic: 'sport', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'sport-024', + motion: 'This House, as a national sports body, would fund only sports where the country can win medals', + resolved: 'Resolved: National sports bodies should fund only sports where the country can win medals.', + topic: 'sport', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'sport-025', + motion: 'This House would require sports governing bodies to be overseen by an independent public regulator', + resolved: 'Resolved: Sports governing bodies should be overseen by an independent public regulator.', + topic: 'sport', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'cult-001', + motion: 'This House would make museums free for everyone', + resolved: 'Resolved: Museums should be free for everyone.', + topic: 'culture', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'cult-002', + motion: 'This House would require radio stations to play a set share of music by local artists', + resolved: 'Resolved: Radio stations should be required to play a set share of music by local artists.', + topic: 'culture', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'cult-003', + motion: 'This House believes that film adaptations should stay faithful to the books they are based on', + resolved: 'Resolved: Film adaptations ought to stay faithful to the books they are based on.', + topic: 'culture', + difficulty: 'novice', + type: 'value', + }, + { + id: 'cult-004', + motion: 'This House, as a film studio, would stop making sequels and remakes for five years', + resolved: 'Resolved: Film studios should stop making sequels and remakes for five years.', + topic: 'culture', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'cult-005', + motion: 'This House prefers music played by live musicians to music made with computers', + resolved: 'Resolved: A world in which most music is played by live musicians is preferable to one in which most music is made with computers.', + topic: 'culture', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'cult-006', + motion: 'This House would let street artists paint legally on chosen public walls', + resolved: 'Resolved: Street artists should be able to paint legally on chosen public walls.', + topic: 'culture', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'cult-007', + motion: 'This House believes that video games are a form of art', + resolved: 'Resolved: Video games are a form of art.', + topic: 'culture', + difficulty: 'novice', + type: 'value', + }, + { + id: 'cult-008', + motion: 'This House regrets the rise of short-form video', + resolved: 'Resolved: The rise of short-form video has done more harm than good.', + topic: 'culture', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'cult-009', + motion: 'This House would return cultural artifacts in museums to the countries they were taken from', + resolved: 'Resolved: Museums should return cultural artifacts to the countries they were taken from.', + topic: 'culture', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'cult-010', + motion: 'This House would ban AI-generated art from art competitions', + resolved: 'Resolved: AI-generated art should be banned from art competitions.', + topic: 'culture', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'cult-011', + motion: 'This House believes that an artist\'s personal conduct should not affect how their work is judged', + resolved: 'Resolved: An artist\'s personal conduct ought not to affect how their work is judged.', + topic: 'culture', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'cult-012', + motion: 'This House, as a streaming service, would show fewer recommendations and let users browse more', + resolved: 'Resolved: Streaming services should show fewer recommendations and let users browse more.', + topic: 'culture', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'cult-013', + motion: 'This House would ban children from appearing in money-making family vlogs', + resolved: 'Resolved: Children should be banned from appearing in money-making family vlogs.', + topic: 'culture', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'cult-014', + motion: 'This House would publicly fund artists with a basic monthly wage', + resolved: 'Resolved: Artists should receive a publicly funded basic monthly wage.', + topic: 'culture', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'cult-015', + motion: 'This House prefers a culture of owning books, music, and films to one of streaming them', + resolved: 'Resolved: A world in which people own their books, music, and films is preferable to one in which they stream them.', + topic: 'culture', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'cult-016', + motion: 'This House would edit old children\'s books to remove outdated language', + resolved: 'Resolved: Publishers should edit old children\'s books to remove outdated language.', + topic: 'culture', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'cult-017', + motion: 'This House believes that celebrities should keep their opinions on politics to themselves', + resolved: 'Resolved: Celebrities ought to keep their opinions on politics to themselves.', + topic: 'culture', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'cult-018', + motion: 'This House would shorten copyright so that creative works enter the public domain ten years after release', + resolved: 'Resolved: Copyright should be shortened so that creative works enter the public domain ten years after release.', + topic: 'culture', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'A work in the public domain is no longer protected by copyright, so anyone may copy, share, or adapt it for free.', + }, + { + id: 'cult-019', + motion: 'This House believes that cultural appropriation is a meaningful harm', + resolved: 'Resolved: Cultural appropriation is a meaningful harm.', + topic: 'culture', + difficulty: 'advanced', + type: 'value', + infoSlide: 'Cultural appropriation means members of one culture adopting elements of another culture, such as clothing, music, or symbols, often without acknowledgement.', + }, + { + id: 'cult-020', + motion: 'This House, as a minority-language community, would require its children to be schooled only in that language until age 11', + resolved: 'Resolved: Minority-language communities should require their children to be schooled only in that language until age 11.', + topic: 'culture', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'cult-021', + motion: 'This House regrets the dominance of English as the global language of culture', + resolved: 'Resolved: The dominance of English as the global language of culture has done more harm than good.', + topic: 'culture', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'cult-022', + motion: 'This House would let AI companies train on copyrighted works only with the creators\' permission', + resolved: 'Resolved: AI companies should be able to train on copyrighted works only with the creators\' permission.', + topic: 'culture', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'cult-023', + motion: 'This House believes that state funding for the arts should favor popular work over critically acclaimed work', + resolved: 'Resolved: State funding for the arts ought to favor popular work over critically acclaimed work.', + topic: 'culture', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'cult-024', + motion: 'This House, as a national broadcaster, would stop chasing ratings and make only programs commercial channels will not', + resolved: 'Resolved: National broadcasters should stop chasing ratings and make only programs that commercial channels will not.', + topic: 'culture', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'cult-025', + motion: 'This House prefers a world where heritage buildings can be freely redeveloped to one where they are strictly preserved', + resolved: 'Resolved: A world in which heritage buildings can be freely redeveloped is preferable to one in which they are strictly preserved.', + topic: 'culture', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'eth-001', + motion: 'This House would always tell a friend the truth about their bad haircut', + resolved: 'Resolved: People should always tell a friend the truth about a bad haircut.', + topic: 'ethics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'eth-002', + motion: 'This House would ban posting photos of strangers online without their permission', + resolved: 'Resolved: Posting photos of strangers online without their permission should be banned.', + topic: 'ethics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'eth-003', + motion: 'This House believes that it is sometimes right to break a promise', + resolved: 'Resolved: It is sometimes right to break a promise.', + topic: 'ethics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'eth-004', + motion: 'This House, as a student who sees a friend cheating on a test, would report it to the teacher', + resolved: 'Resolved: Students who see a friend cheating on a test should report it to the teacher.', + topic: 'ethics', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'eth-005', + motion: 'This House regrets the culture of posting every good deed online', + resolved: 'Resolved: The culture of posting every good deed online has done more harm than good.', + topic: 'ethics', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'eth-006', + motion: 'This House would require everyone to do a year of community service after leaving school', + resolved: 'Resolved: Everyone should do a year of community service after leaving school.', + topic: 'ethics', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'eth-007', + motion: 'This House believes that zoos do more good than harm for animals', + resolved: 'Resolved: Zoos do more good than harm for animals.', + topic: 'ethics', + difficulty: 'novice', + type: 'value', + }, + { + id: 'eth-008', + motion: 'This House prefers being liked to being respected', + resolved: 'Resolved: A world in which people value being liked is preferable to one in which they value being respected.', + topic: 'ethics', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'eth-009', + motion: 'This House would make it a legal duty to help a stranger in danger when it is safe to do so', + resolved: 'Resolved: It should be a legal duty to help a stranger in danger when it is safe to do so.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'eth-010', + motion: 'This House would ban the breeding of animals purely for their looks', + resolved: 'Resolved: The breeding of animals purely for their looks should be banned.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'eth-011', + motion: 'This House believes that people have a moral duty to give a tenth of their income to effective charities', + resolved: 'Resolved: People have a moral duty to give a tenth of their income to effective charities.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'eth-012', + motion: 'This House, as a whistleblower at a tech company, would leak evidence of wrongdoing to the press rather than report it internally', + resolved: 'Resolved: Whistleblowers at tech companies should leak evidence of wrongdoing to the press rather than report it internally.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'eth-013', + motion: 'This House regrets the rise of public shaming on the internet', + resolved: 'Resolved: The rise of public shaming on the internet has done more harm than good.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'eth-014', + motion: 'This House would let parents choose the genes of their children to prevent serious disease', + resolved: 'Resolved: Parents should be able to choose the genes of their children to prevent serious disease.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'eth-015', + motion: 'This House would ban products made using animal testing', + resolved: 'Resolved: Products made using animal testing should be banned.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'eth-016', + motion: 'This House believes that a good intention can excuse a bad outcome', + resolved: 'Resolved: A good intention can excuse a bad outcome.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'eth-017', + motion: 'This House prefers a society that rewards effort to one that rewards results', + resolved: 'Resolved: A world in which society rewards effort is preferable to one in which it rewards results.', + topic: 'ethics', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'eth-018', + motion: 'This House would give legal rights to rivers, forests, and other natural features', + resolved: 'Resolved: Rivers, forests, and other natural features should be given legal rights.', + topic: 'ethics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'eth-019', + motion: 'This House believes that we have stronger moral duties to people near us than to strangers far away', + resolved: 'Resolved: People have stronger moral duties to those near them than to strangers far away.', + topic: 'ethics', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'eth-020', + motion: 'This House, as a self-driving car maker, would program cars to protect pedestrians over passengers', + resolved: 'Resolved: Self-driving car makers should program cars to protect pedestrians over passengers.', + topic: 'ethics', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'eth-021', + motion: 'This House regrets the trend of judging historical figures by today\'s moral standards', + resolved: 'Resolved: The trend of judging historical figures by today\'s moral standards has done more harm than good.', + topic: 'ethics', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'eth-022', + motion: 'This House would grow meat in laboratories and phase out farming animals for food', + resolved: 'Resolved: Societies should grow meat in laboratories and phase out farming animals for food.', + topic: 'ethics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'eth-023', + motion: 'This House believes that people are morally responsible for the actions of the companies they invest in', + resolved: 'Resolved: People are morally responsible for the actions of the companies they invest in.', + topic: 'ethics', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'eth-024', + motion: 'This House, as a charity, would accept large donations from companies with harmful records', + resolved: 'Resolved: Charities should accept large donations from companies with harmful records.', + topic: 'ethics', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'eth-025', + motion: 'This House would ban AI chatbots that imitate dead loved ones', + resolved: 'Resolved: AI chatbots that imitate dead loved ones should be banned.', + topic: 'ethics', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'intl-001', + motion: 'This House would make learning a second language compulsory from the first year of school', + resolved: 'Resolved: Learning a second language should be compulsory from the first year of school.', + topic: 'international', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'intl-002', + motion: 'This House would let anyone travel to any country without a visa for holidays of up to 30 days', + resolved: 'Resolved: Anyone should be able to travel to any country without a visa for holidays of up to 30 days.', + topic: 'international', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'intl-003', + motion: 'This House believes that international student exchanges should be part of every child\'s education', + resolved: 'Resolved: International student exchanges ought to be part of every child\'s education.', + topic: 'international', + difficulty: 'novice', + type: 'value', + }, + { + id: 'intl-004', + motion: 'This House, as a popular tourist city, would cap the number of visitors allowed each day', + resolved: 'Resolved: Popular tourist cities should cap the number of visitors allowed each day.', + topic: 'international', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'intl-005', + motion: 'This House prefers vacations at home to vacations abroad', + resolved: 'Resolved: A world in which people vacation in their own country is preferable to one in which they vacation abroad.', + topic: 'international', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'intl-006', + motion: 'This House would give every country an equal number of places in the Olympic Games', + resolved: 'Resolved: Every country should get an equal number of places in the Olympic Games.', + topic: 'international', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'intl-007', + motion: 'This House believes that rich countries should give more of their wealth to poorer countries', + resolved: 'Resolved: Rich countries ought to give more of their wealth to poorer countries.', + topic: 'international', + difficulty: 'novice', + type: 'value', + }, + { + id: 'intl-008', + motion: 'This House regrets the rise of mass tourism', + resolved: 'Resolved: The rise of mass tourism has done more harm than good.', + topic: 'international', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'intl-009', + motion: 'This House would cancel the debts that poor countries owe to rich ones', + resolved: 'Resolved: Rich countries should cancel the debts that poor countries owe them.', + topic: 'international', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'intl-010', + motion: 'This House would require countries to accept a quota of refugees set by their size and wealth', + resolved: 'Resolved: Countries should be required to accept a quota of refugees set by their size and wealth.', + topic: 'international', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'intl-011', + motion: 'This House believes that foreign aid should come with conditions on how it is spent', + resolved: 'Resolved: Foreign aid ought to come with conditions on how it is spent.', + topic: 'international', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'intl-012', + motion: 'This House, as the European Union, would let any country in Europe join a looser outer tier of membership', + resolved: 'Resolved: The European Union should let any country in Europe join a looser outer tier of membership.', + topic: 'international', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'intl-013', + motion: 'This House prefers giving aid as direct cash transfers to giving it through development projects', + resolved: 'Resolved: A world in which aid is given as direct cash transfers is preferable to one in which it is given through development projects.', + topic: 'international', + difficulty: 'intermediate', + type: 'prefer', + }, + { + id: 'intl-014', + motion: 'This House would use economic sanctions only when they target named individuals rather than whole countries', + resolved: 'Resolved: Economic sanctions should target only named individuals rather than whole countries.', + topic: 'international', + difficulty: 'intermediate', + type: 'policy', + infoSlide: 'Economic sanctions are penalties, such as trade bans or frozen bank accounts, that one country places on another to pressure it.', + }, + { + id: 'intl-015', + motion: 'This House would let citizens of any country vote in a global election for the head of the United Nations', + resolved: 'Resolved: Citizens of every country should vote in a global election for the head of the United Nations.', + topic: 'international', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'intl-016', + motion: 'This House believes that international sporting bans are an effective way to pressure governments', + resolved: 'Resolved: International sporting bans are an effective way to pressure governments.', + topic: 'international', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'intl-017', + motion: 'This House regrets the decline of global trade agreements in favor of regional ones', + resolved: 'Resolved: The decline of global trade agreements in favor of regional ones has done more harm than good.', + topic: 'international', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'intl-018', + motion: 'This House would abolish the veto held by permanent members of the UN Security Council', + resolved: 'Resolved: The veto held by permanent members of the UN Security Council should be abolished.', + topic: 'international', + difficulty: 'advanced', + type: 'policy', + infoSlide: 'Five permanent members of the UN Security Council can each block any resolution on their own by using a veto.', + }, + { + id: 'intl-019', + motion: 'This House believes that the principle of non-interference in other countries\' affairs has outlived its usefulness', + resolved: 'Resolved: The principle of non-interference in other countries\' affairs has outlived its usefulness.', + topic: 'international', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'intl-020', + motion: 'This House, as the African Union, would create a single currency for its member states', + resolved: 'Resolved: The African Union should create a single currency for its member states.', + topic: 'international', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'intl-021', + motion: 'This House would create an international court with power to try companies for environmental destruction', + resolved: 'Resolved: An international court should be created with power to try companies for environmental destruction.', + topic: 'international', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'intl-022', + motion: 'This House believes that global institutions should give more voting weight to countries with larger populations', + resolved: 'Resolved: Global institutions ought to give more voting weight to countries with larger populations.', + topic: 'international', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'intl-023', + motion: 'This House would let countries sell citizenship to wealthy investors', + resolved: 'Resolved: Countries should be allowed to sell citizenship to wealthy investors.', + topic: 'international', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'intl-024', + motion: 'This House, as a low-income country, would accept large infrastructure loans from foreign powers', + resolved: 'Resolved: Low-income countries should accept large infrastructure loans from foreign powers.', + topic: 'international', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'intl-025', + motion: 'This House prefers a world with several competing great powers to one with a single dominant power', + resolved: 'Resolved: A world in which several great powers compete is preferable to one in which a single power dominates.', + topic: 'international', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'just-001', + motion: 'This House would let young offenders meet their victims instead of going to court for minor crimes', + resolved: 'Resolved: Young offenders who commit minor crimes should meet their victims instead of going to court.', + topic: 'justice', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'just-002', + motion: 'This House would require police officers to wear body cameras at all times on duty', + resolved: 'Resolved: Police officers should wear body cameras at all times on duty.', + topic: 'justice', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'just-003', + motion: 'This House believes that prisons should focus on helping prisoners change rather than on punishment', + resolved: 'Resolved: Prisons ought to focus on helping prisoners change rather than on punishment.', + topic: 'justice', + difficulty: 'novice', + type: 'value', + }, + { + id: 'just-004', + motion: 'This House, as a school, would let students sit on the panel that decides punishments for breaking school rules', + resolved: 'Resolved: Schools should let students sit on the panel that decides punishments for breaking school rules.', + topic: 'justice', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'just-005', + motion: 'This House regrets the popularity of true crime entertainment', + resolved: 'Resolved: The popularity of true crime entertainment has done more harm than good.', + topic: 'justice', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'just-006', + motion: 'This House would make fines for traffic offenses depend on how much the driver earns', + resolved: 'Resolved: Fines for traffic offenses should depend on how much the driver earns.', + topic: 'justice', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'just-007', + motion: 'This House believes that community service is a better punishment than a short prison sentence', + resolved: 'Resolved: Community service is a better punishment than a short prison sentence.', + topic: 'justice', + difficulty: 'novice', + type: 'value', + }, + { + id: 'just-008', + motion: 'This House prefers more police officers on the street to more security cameras', + resolved: 'Resolved: A world in which more police officers patrol the streets is preferable to one in which more security cameras are installed.', + topic: 'justice', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'just-009', + motion: 'This House would ban police from using prediction software to decide where to patrol', + resolved: 'Resolved: Police should be banned from using prediction software to decide where to patrol.', + topic: 'justice', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'just-010', + motion: 'This House would let people convicted of crimes vote while in prison', + resolved: 'Resolved: People convicted of crimes should be able to vote while in prison.', + topic: 'justice', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'just-011', + motion: 'This House believes that the media should not name people accused of crimes until they are convicted', + resolved: 'Resolved: The media ought not to name people accused of crimes until they are convicted.', + topic: 'justice', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'just-012', + motion: 'This House, as a city police chief, would stop arresting people for possessing small amounts of drugs', + resolved: 'Resolved: City police chiefs should stop arresting people for possessing small amounts of drugs.', + topic: 'justice', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'just-013', + motion: 'This House regrets the use of plea bargains', + resolved: 'Resolved: The use of plea bargains has done more harm than good.', + topic: 'justice', + difficulty: 'intermediate', + type: 'regret', + infoSlide: 'A plea bargain is a deal in which a defendant pleads guilty, usually to a lesser charge, in exchange for a lighter sentence.', + }, + { + id: 'just-014', + motion: 'This House would replace juries with panels of trained judges', + resolved: 'Resolved: Juries should be replaced with panels of trained judges.', + topic: 'justice', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'just-015', + motion: 'This House would wipe the criminal records of people who committed minor offenses as teenagers', + resolved: 'Resolved: The criminal records of people who committed minor offenses as teenagers should be wiped.', + topic: 'justice', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'just-016', + motion: 'This House believes that victims\' families should have a say in the sentences given to offenders', + resolved: 'Resolved: Victims\' families ought to have a say in the sentences given to offenders.', + topic: 'justice', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'just-017', + motion: 'This House would let private companies run prisons only if they are paid by how few of their prisoners reoffend', + resolved: 'Resolved: Private companies should run prisons only if they are paid by how few of their prisoners reoffend.', + topic: 'justice', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'just-018', + motion: 'This House would use AI to recommend sentences to judges', + resolved: 'Resolved: Courts should use AI to recommend sentences to judges.', + topic: 'justice', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'just-019', + motion: 'This House believes that it is better for ten guilty people to go free than for one innocent person to be convicted', + resolved: 'Resolved: It is better for ten guilty people to go free than for one innocent person to be convicted.', + topic: 'justice', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'just-020', + motion: 'This House, as a defense lawyer who privately believes their client is guilty, would still argue for an acquittal', + resolved: 'Resolved: Defense lawyers who privately believe their clients are guilty should still argue for acquittal.', + topic: 'justice', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'just-021', + motion: 'This House regrets the growing use of courts to settle political questions', + resolved: 'Resolved: The growing use of courts to settle political questions has done more harm than good.', + topic: 'justice', + difficulty: 'advanced', + type: 'regret', + }, + { + id: 'just-022', + motion: 'This House would let judges be elected by the public', + resolved: 'Resolved: Judges should be elected by the public.', + topic: 'justice', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'just-023', + motion: 'This House believes that punishment should be based on the harm a crime caused rather than on what the offender intended', + resolved: 'Resolved: Punishment ought to be based on the harm a crime caused rather than on what the offender intended.', + topic: 'justice', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'just-024', + motion: 'This House, as a country emerging from conflict, would offer amnesty to former fighters in exchange for full confessions', + resolved: 'Resolved: Countries emerging from conflict should offer amnesty to former fighters in exchange for full confessions.', + topic: 'justice', + difficulty: 'advanced', + type: 'actor', + infoSlide: 'An amnesty is an official pardon that means people will not be prosecuted for certain past crimes.', + }, + { + id: 'just-025', + motion: 'This House prefers fixed sentences for each crime to letting judges choose sentences freely', + resolved: 'Resolved: A world in which each crime carries a fixed sentence is preferable to one in which judges choose sentences freely.', + topic: 'justice', + difficulty: 'advanced', + type: 'prefer', + }, + { + id: 'soc-001', + motion: 'This House would ban children under 14 from having social media accounts', + resolved: 'Resolved: Children under 14 should be banned from having social media accounts.', + topic: 'society', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'soc-002', + motion: 'This House would give every teenager a free bus pass', + resolved: 'Resolved: Every teenager should get a free bus pass.', + topic: 'society', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'soc-003', + motion: 'This House believes that it is better to grow up in a big city than in the countryside', + resolved: 'Resolved: It is better to grow up in a big city than in the countryside.', + topic: 'society', + difficulty: 'novice', + type: 'value', + }, + { + id: 'soc-004', + motion: 'This House, as a parent, would let their child decide when they get their first phone', + resolved: 'Resolved: Parents should let their children decide when they get their first phone.', + topic: 'society', + difficulty: 'novice', + type: 'actor', + }, + { + id: 'soc-005', + motion: 'This House prefers having a few close friends to having many casual friends', + resolved: 'Resolved: A world in which people have a few close friends is preferable to one in which they have many casual friends.', + topic: 'society', + difficulty: 'novice', + type: 'prefer', + }, + { + id: 'soc-006', + motion: 'This House would make every neighborhood have a free youth center', + resolved: 'Resolved: Every neighborhood should have a free youth center.', + topic: 'society', + difficulty: 'novice', + type: 'policy', + }, + { + id: 'soc-007', + motion: 'This House believes that teenagers today have it harder than their parents did', + resolved: 'Resolved: Teenagers today have it harder than their parents did.', + topic: 'society', + difficulty: 'novice', + type: 'value', + }, + { + id: 'soc-008', + motion: 'This House regrets the rise of group chats as the main way friends talk', + resolved: 'Resolved: The rise of group chats as the main way friends talk has done more harm than good.', + topic: 'society', + difficulty: 'novice', + type: 'regret', + }, + { + id: 'soc-009', + motion: 'This House would require social media platforms to switch off infinite scrolling by default', + resolved: 'Resolved: Social media platforms should switch off infinite scrolling by default.', + topic: 'society', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'soc-010', + motion: 'This House would require large housing developments to include homes for older and younger people side by side', + resolved: 'Resolved: Large housing developments should include homes for older and younger people side by side.', + topic: 'society', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'soc-011', + motion: 'This House believes that loneliness should be treated as a public health crisis', + resolved: 'Resolved: Loneliness ought to be treated as a public health crisis.', + topic: 'society', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'soc-012', + motion: 'This House, as a social media company, would hide like counts on all posts', + resolved: 'Resolved: Social media companies should hide like counts on all posts.', + topic: 'society', + difficulty: 'intermediate', + type: 'actor', + }, + { + id: 'soc-013', + motion: 'This House prefers living with extended family to living as a nuclear family', + resolved: 'Resolved: A world in which people live with their extended families is preferable to one in which they live as nuclear families.', + topic: 'society', + difficulty: 'intermediate', + type: 'prefer', + infoSlide: 'A nuclear family is parents and their children living together, while an extended family also includes grandparents, aunts, uncles, or cousins.', + }, + { + id: 'soc-014', + motion: 'This House would give equal paid leave to both parents after the birth of a child', + resolved: 'Resolved: Both parents should get equal paid leave after the birth of a child.', + topic: 'society', + difficulty: 'intermediate', + type: 'policy', + }, + { + id: 'soc-015', + motion: 'This House would turn empty office buildings into social housing', + resolved: 'Resolved: Empty office buildings should be turned into social housing.', + topic: 'society', + difficulty: 'intermediate', + type: 'policy', + infoSlide: 'Social housing is housing owned by the government or a non-profit and rented at a lower price to people who need it.', + }, + { + id: 'soc-016', + motion: 'This House believes that working from home has been good for society', + resolved: 'Resolved: Working from home has been good for society.', + topic: 'society', + difficulty: 'intermediate', + type: 'value', + }, + { + id: 'soc-017', + motion: 'This House regrets the rise of dating apps', + resolved: 'Resolved: The rise of dating apps has done more harm than good.', + topic: 'society', + difficulty: 'intermediate', + type: 'regret', + }, + { + id: 'soc-018', + motion: 'This House would require all new neighborhoods to mix homes of different prices on the same streets', + resolved: 'Resolved: All new neighborhoods should mix homes of different prices on the same streets.', + topic: 'society', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'soc-019', + motion: 'This House believes that meritocracy is a myth that harms those who do not succeed', + resolved: 'Resolved: Meritocracy is a myth that harms those who do not succeed.', + topic: 'society', + difficulty: 'advanced', + type: 'value', + infoSlide: 'Meritocracy is the idea that people rise in society based on their talent and effort rather than their background.', + }, + { + id: 'soc-020', + motion: 'This House, as a feminist movement, would campaign for men\'s issues such as boys falling behind in school', + resolved: 'Resolved: Feminist movements should campaign for men\'s issues such as boys falling behind in school.', + topic: 'society', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'soc-021', + motion: 'This House would let communities vote to keep chain stores out of their town centers', + resolved: 'Resolved: Communities should be able to vote to keep chain stores out of their town centers.', + topic: 'society', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'soc-022', + motion: 'This House believes that identity-based activism does more to divide society than unite it', + resolved: 'Resolved: Identity-based activism does more to divide society than to unite it.', + topic: 'society', + difficulty: 'advanced', + type: 'value', + }, + { + id: 'soc-023', + motion: 'This House, as a city with an aging population, would pay young families to move there', + resolved: 'Resolved: Cities with aging populations should pay young families to move there.', + topic: 'society', + difficulty: 'advanced', + type: 'actor', + }, + { + id: 'soc-024', + motion: 'This House would give every adult a fixed number of hours of paid leave to care for older relatives', + resolved: 'Resolved: Every adult should have a fixed number of hours of paid leave to care for older relatives.', + topic: 'society', + difficulty: 'advanced', + type: 'policy', + }, + { + id: 'soc-025', + motion: 'This House prefers a society with strong shared traditions to one that encourages individual self-expression', + resolved: 'Resolved: A world in which society has strong shared traditions is preferable to one that encourages individual self-expression.', + topic: 'society', + difficulty: 'advanced', + type: 'prefer', + }, +]; diff --git a/packages/core/src/free-tools/round-timer.ts b/packages/core/src/free-tools/round-timer.ts new file mode 100644 index 000000000..5e872b92d --- /dev/null +++ b/packages/core/src/free-tools/round-timer.ts @@ -0,0 +1,167 @@ +/** + * The free round timer's state, as pure functions of wall-clock time so a + * running round survives a reload. A speech counts up from zero and keeps + * counting past its limit (overtime); prep banks are the in-app countdown + * clocks from timer-clock.ts and stop at zero. + */ +import { pauseClock, remainingMs, startClock, type ClockState } from '../timer-clock'; +import type { RoundFormat, RoundSegment } from './formats'; + +export interface Stopwatch { + /** Milliseconds counted before the current run. */ + elapsedMs: number; + /** When the current run began, while running. */ + startedAt: number | null; +} + +export type PrepSide = 'a' | 'b'; + +export interface RoundState { + segmentIndex: number; + speech: Stopwatch; + prep: Record; +} + +export function elapsedMs(watch: Stopwatch, now: number): number { + return watch.startedAt === null ? watch.elapsedMs : watch.elapsedMs + Math.max(0, now - watch.startedAt); +} + +const stoppedAt = (watch: Stopwatch, now: number): Stopwatch => ({ elapsedMs: elapsedMs(watch, now), startedAt: null }); +const PAUSED: Stopwatch = { elapsedMs: 0, startedAt: null }; + +export function initialRoundState(format: RoundFormat): RoundState { + const bank = (): ClockState => ({ remainingMs: format.prepSeconds * 1000, deadline: null }); + return { segmentIndex: 0, speech: PAUSED, prep: { a: bank(), b: bank() } }; +} + +export const isSpeechRunning = (state: RoundState) => state.speech.startedAt !== null; +export const isPrepRunning = (state: RoundState, side: PrepSide) => state.prep[side].deadline !== null; + +function pausePrep(prep: RoundState['prep'], now: number): RoundState['prep'] { + return { a: pauseClock(prep.a, now), b: pauseClock(prep.b, now) }; +} + +/** Starts or pauses the speech. A speech and prep never run together: starting one stops the other. */ +export function toggleSpeech(state: RoundState, now: number): RoundState { + if (isSpeechRunning(state)) return { ...state, speech: stoppedAt(state.speech, now) }; + return { ...state, speech: { elapsedMs: state.speech.elapsedMs, startedAt: now }, prep: pausePrep(state.prep, now) }; +} + +/** Starts or pauses one side's prep bank; the other side's bank and the speech pause. An empty bank stays put. */ +export function togglePrep(state: RoundState, side: PrepSide, now: number): RoundState { + if (isPrepRunning(state, side)) return { ...state, prep: { ...state.prep, [side]: pauseClock(state.prep[side], now) } }; + const prep = pausePrep(state.prep, now); + return { ...state, speech: stoppedAt(state.speech, now), prep: { ...prep, [side]: startClock(prep[side], now) } }; +} + +export function prepRemainingMs(state: RoundState, side: PrepSide, now: number): number { + return remainingMs(state.prep[side], now); +} + +/** Pauses a prep bank that has run out, so a reload does not show it running at 0:00. */ +export function settlePrep(state: RoundState, now: number): RoundState { + const settled = (side: PrepSide) => (isPrepRunning(state, side) && prepRemainingMs(state, side, now) === 0 + ? { remainingMs: 0, deadline: null } + : state.prep[side]); + const a = settled('a'); + const b = settled('b'); + return a === state.prep.a && b === state.prep.b ? state : { ...state, prep: { a, b } }; +} + +/** + * Moves to another segment with a fresh, paused speech clock. Prep banks carry + * over. Asking for the segment already shown, including past either end of the + * round, changes nothing, so a stray arrow key never wipes a running speech. + */ +export function goToSegment(state: RoundState, format: RoundFormat, index: number, now: number): RoundState { + const segmentIndex = Math.min(Math.max(0, index), format.segments.length - 1); + if (segmentIndex === state.segmentIndex) return state; + return { segmentIndex, speech: PAUSED, prep: pausePrep(state.prep, now) }; +} + +export function resetSpeech(state: RoundState): RoundState { + return { ...state, speech: PAUSED }; +} + +/** Restores both prep banks to the format's full allowance. */ +export function resetPrep(state: RoundState, format: RoundFormat): RoundState { + return { ...state, prep: initialRoundState(format).prep }; +} + +export type PoiState = 'protected' | 'open'; + +export interface SegmentStatus { + /** Milliseconds left; negative once the speaker is over time. */ + remainingMs: number; + overtime: boolean; + /** Over time but inside the format's grace period. */ + inGrace: boolean; + /** Only for speeches that take points of information. */ + poi: PoiState | null; +} + +export function segmentStatus(segment: RoundSegment, elapsed: number, graceSeconds = 0): SegmentStatus { + const limit = segment.seconds * 1000; + const remaining = limit - elapsed; + const overtime = remaining < 0; + const poi = segment.poi + ? (elapsed >= segment.poi.opensAt * 1000 && elapsed < segment.poi.closesAt * 1000 ? 'open' : 'protected') + : null; + return { remainingMs: remaining, overtime, inGrace: overtime && -remaining <= graceSeconds * 1000, poi }; +} + +export type TimerCue = 'knock' | 'beep' | 'double-beep'; + +/** + * The cue to sound when the clock moves from `before` to `after` elapsed + * milliseconds: a knock when points of information open or close, a beep with + * one minute left, a double beep at time. Marks that fall together sound once, + * the most important first. + */ +export function cueBetween(segment: RoundSegment, before: number, after: number): TimerCue | null { + if (after <= before) return null; + const crossed = (seconds: number) => before < seconds * 1000 && after >= seconds * 1000; + if (crossed(segment.seconds)) return 'double-beep'; + if (segment.poi && (crossed(segment.poi.opensAt) || crossed(segment.poi.closesAt))) return 'knock'; + if (segment.seconds > 60 && crossed(segment.seconds - 60)) return 'beep'; + return null; +} + +/** "-0:12" style: overtime reads as time past the limit, counting up. */ +export function clockFace(remaining: number): { text: string; overtime: boolean } { + const overtime = remaining < 0; + // Counting down, 0.2 s left still reads 0:01; counting up, 0.2 s over reads +0:00. + const seconds = overtime ? Math.floor(-remaining / 1000) : Math.ceil(remaining / 1000); + const text = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; + return { text: overtime ? `+${text}` : text, overtime }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; +const isTimeOrNull = (value: unknown): value is number | null => value === null || isTime(value); + +/** A stored state read back from localStorage, or null when it does not fit the format. */ +export function parseRoundState(value: unknown, format: RoundFormat): RoundState | null { + if (!isRecord(value) || !isRecord(value.speech) || !isRecord(value.prep)) return null; + const { segmentIndex, speech, prep } = value; + if (!Number.isInteger(segmentIndex) || Number(segmentIndex) < 0 || Number(segmentIndex) >= format.segments.length) return null; + const { elapsedMs: counted, startedAt } = speech; + if (!isTime(counted) || !isTimeOrNull(startedAt)) return null; + const bank = (side: unknown): ClockState | null => { + if (!isRecord(side)) return null; + const { remainingMs: left, deadline } = side; + if (!isTime(left) || !isTimeOrNull(deadline)) return null; + return { remainingMs: Math.min(left, format.prepSeconds * 1000), deadline }; + }; + const a = bank(prep.a); + const b = bank(prep.b); + if (!a || !b) return null; + return { + segmentIndex: Number(segmentIndex), + speech: { elapsedMs: counted, startedAt }, + prep: { a, b }, + }; +} diff --git a/packages/core/src/free-tools/speech-time.ts b/packages/core/src/free-tools/speech-time.ts new file mode 100644 index 000000000..fc6a14d5b --- /dev/null +++ b/packages/core/src/free-tools/speech-time.ts @@ -0,0 +1,60 @@ +/** Word counts and spoken-duration estimates for the speech time calculator. */ +import { segmentRole, speakingSegments, type RoundFormat } from './formats'; + +/** Rough speaking rates. These are working approximations for practice, not measured norms. */ +export const PACES = [ + { id: 'slow', wpm: 110 }, + { id: 'average', wpm: 130 }, + { id: 'fast', wpm: 160 }, +] as const; +export type PaceId = (typeof PACES)[number]['id']; + +export const MIN_WPM = 60; +export const MAX_WPM = 400; + +/** Words as a reader counts them: "don't", "well-known" and "2026" are one word each; dashes and bullets are none. */ +export function countWords(text: string): number { + return text.match(/[\p{L}\p{N}]+(?:['’\-.][\p{L}\p{N}]+)*/gu)?.length ?? 0; +} + +export function spokenSeconds(words: number, wpm: number): number { + if (!(wpm > 0)) return 0; + return (words / wpm) * 60; +} + +export interface SlotFit { + formatId: string; + formatShortName: string; + /** The speeches of this length, by role: "constructive / rebuttal". */ + roles: string; + slotSeconds: number; + /** Positive when the speech fits with time to spare; negative when it runs over. */ + spareSeconds: number; + fits: boolean; +} + +/** + * How a speech of `words` words at `wpm` fits each distinct speech length in + * the formats, one row per format and length. + */ +export function slotFits(words: number, wpm: number, formats: readonly RoundFormat[]): SlotFit[] { + const spoken = spokenSeconds(words, wpm); + return formats.flatMap((format) => { + const byLength = new Map(); + for (const segment of speakingSegments(format)) { + const roles = byLength.get(segment.seconds) ?? []; + const role = segmentRole(segment); + if (!roles.includes(role)) roles.push(role); + byLength.set(segment.seconds, roles); + } + return [...byLength].map(([slotSeconds, roles]) => { + const spareSeconds = Math.round(slotSeconds - spoken); + return { formatId: format.id, formatShortName: format.shortName, roles: roles.join(' / '), slotSeconds, spareSeconds, fits: spareSeconds >= 0 }; + }); + }); +} + +/** Words that fill a slot at a pace: the target length for a speech you are writing. */ +export function wordsForSlot(slotSeconds: number, wpm: number): number { + return Math.round((slotSeconds / 60) * wpm); +} diff --git a/packages/core/src/globals.d.ts b/packages/core/src/globals.d.ts index 209dbf432..74e39980f 100644 --- a/packages/core/src/globals.d.ts +++ b/packages/core/src/globals.d.ts @@ -22,8 +22,10 @@ declare class URL { // WHATWG URL parameters are also available in Hermes. declare class URLSearchParams { - constructor(); + constructor(init?: string); append(name: string, value: string): void; + get(name: string): string | null; + set(name: string, value: string): void; forEach(callback: (value: string, key: string) => void): void; toString(): string; } diff --git a/packages/core/src/timer-clock.ts b/packages/core/src/timer-clock.ts new file mode 100644 index 000000000..c563bdb01 --- /dev/null +++ b/packages/core/src/timer-clock.ts @@ -0,0 +1,29 @@ +/** + * Speech clock arithmetic in milliseconds. Pausing keeps the exact remainder, so a + * pause never hands the speaker the rest of the current second. + */ +export interface ClockState { + /** Milliseconds left while paused; ignored while running. */ + remainingMs: number; + /** When the speech ends, while running. */ + deadline: number | null; +} + +export function startClock(state: ClockState, now: number): ClockState { + if (state.deadline !== null || state.remainingMs <= 0) return state; + return { remainingMs: state.remainingMs, deadline: now + state.remainingMs }; +} + +export function pauseClock(state: ClockState, now: number): ClockState { + if (state.deadline === null) return state; + return { remainingMs: Math.max(0, state.deadline - now), deadline: null }; +} + +export function remainingMs(state: ClockState, now: number): number { + return state.deadline === null ? state.remainingMs : Math.max(0, state.deadline - now); +} + +/** Whole seconds shown on the clock face: 0.2s left still reads 0:01. */ +export function displaySeconds(ms: number): number { + return Math.ceil(ms / 1000); +} diff --git a/src/lib/free-tools/custom-format.ts b/src/lib/free-tools/custom-format.ts index 813c3ef55..6a3776ef8 100644 --- a/src/lib/free-tools/custom-format.ts +++ b/src/lib/free-tools/custom-format.ts @@ -1,37 +1,13 @@ /** * A coach's own round format, small enough to share in a URL (`?custom=`). * The parameter is untrusted input: anything that does not fit the limits - * below is rejected rather than repaired. + * below is rejected rather than repaired. The format itself is shared with + * the app in @debatelab/core. */ -import type { RoundFormat, RoundSegment, SegmentSide } from './formats'; +import type { SegmentSide } from './formats'; +import { cleanText, CUSTOM_LIMITS, isWholeSeconds, type CustomFormat, type CustomSegment } from '@debatelab/core/free-tools/custom-format'; -export interface CustomSegment { - label: string; - seconds: number; - /** Questioning periods have no points of information and no flow column. */ - kind: 'speech' | 'cross-examination'; - side: SegmentSide; - /** Points of information between the first and last minute. */ - poi: boolean; -} - -export interface CustomFormat { - name: string; - /** Prep time per side, in seconds. */ - prepSeconds: number; - segments: CustomSegment[]; -} - -export const CUSTOM_LIMITS = { - segments: 30, - label: 40, - name: 60, - minSeconds: 10, - maxSeconds: 60 * 60, - maxPrepSeconds: 30 * 60, - /** Encoded length; well under any browser's URL limit. */ - encoded: 4000, -} as const; +export * from '@debatelab/core/free-tools/custom-format'; type WireSegment = [label: string, seconds: number, kind: 's' | 'q', side: SegmentSide, poi: 0 | 1]; interface Wire { v: 1; n: string; p: number; s: WireSegment[] } @@ -48,12 +24,6 @@ function fromBase64Url(value: string): string { return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))); } -/** Control characters become spaces, runs of space collapse, and the result is cut to `max`. */ -function cleanText(value: string, max: number): string { - const visible = Array.from(value, (char) => (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 ? ' ' : char)).join(''); - return visible.replace(/\s+/g, ' ').trim().slice(0, max); -} - export function encodeCustomFormat(format: CustomFormat): string { const wire: Wire = { v: 1, @@ -64,9 +34,6 @@ export function encodeCustomFormat(format: CustomFormat): string { return toBase64Url(JSON.stringify(wire)); } -const isWholeSeconds = (value: unknown, min: number, max: number): value is number => - Number.isInteger(value) && Number(value) >= min && Number(value) <= max; - /** The custom format in a `?custom=` value, or null when it is malformed or out of bounds. */ export function decodeCustomFormat(value: string | null | undefined): CustomFormat | null { if (!value || value.length > CUSTOM_LIMITS.encoded || !/^[A-Za-z0-9_-]+$/.test(value)) return null; @@ -94,42 +61,3 @@ export function decodeCustomFormat(value: string | null | undefined): CustomForm return { name: cleanText(n, CUSTOM_LIMITS.name) || 'Custom format', prepSeconds: p, segments }; } -/** The timer's view of a custom format. POI windows need at least a minute between the protected ends. */ -export function customToRoundFormat(custom: CustomFormat): RoundFormat { - const segments: RoundSegment[] = custom.segments.map((segment, index) => ({ - id: `c${index + 1}`, - short: segment.label, - label: segment.label, - side: segment.side, - kind: segment.kind, - seconds: segment.seconds, - ...(segment.poi && segment.kind === 'speech' && segment.seconds >= 180 - ? { poi: { opensAt: 60, closesAt: segment.seconds - 60 } } - : {}), - })); - return { - id: 'custom', - name: custom.name, - shortName: 'Custom', - sideNames: ['Side A', 'Side B'], - segments, - prepSeconds: custom.prepSeconds, - sources: [], - note: 'A custom format. Share its link so your club times rounds the same way.', - }; -} - -/** A starting point for editing: a built-in format as a custom one. */ -export function roundFormatToCustom(format: RoundFormat): CustomFormat { - return { - name: `${format.name} (custom)`.slice(0, CUSTOM_LIMITS.name), - prepSeconds: format.prepSeconds, - segments: format.segments.map((segment) => ({ - label: segment.short.slice(0, CUSTOM_LIMITS.label), - seconds: segment.seconds, - kind: segment.kind === 'speech' || segment.kind === 'reply' ? 'speech' : 'cross-examination', - side: segment.side, - poi: Boolean(segment.poi), - })), - }; -} diff --git a/src/lib/free-tools/formats.ts b/src/lib/free-tools/formats.ts index 80d942a39..d2ee22651 100644 --- a/src/lib/free-tools/formats.ts +++ b/src/lib/free-tools/formats.ts @@ -1,245 +1 @@ -/** - * Speech orders and times for the free debate tools (timer, flow sheet, speech - * calculator). Every number comes from the source listed with its format; - * leagues and tournaments vary, so the timer lets a coach change any of them. - */ - -export type SegmentKind = 'speech' | 'reply' | 'cross-examination' | 'crossfire'; -/** `a` speaks first in the format's own terms (Affirmative, Government, Team A); `both` is a shared period. */ -export type SegmentSide = 'a' | 'b' | 'both'; - -export interface RoundSegment { - id: string; - /** Short name on buttons and flow-sheet columns: "1AC", "PM". */ - short: string; - /** Full name: "First Affirmative Constructive". */ - label: string; - side: SegmentSide; - kind: SegmentKind; - seconds: number; - /** Elapsed seconds between which points of information may be offered; outside it the speech is protected. */ - poi?: { opensAt: number; closesAt: number }; - /** What both sides' speech in this slot is called ("constructive"), when the short name names a side. */ - role?: string; -} - -export interface FormatSource { - label: string; - url: string; -} - -export interface RoundFormat { - id: string; - name: string; - shortName: string; - sideNames: readonly [string, string]; - segments: readonly RoundSegment[]; - /** In-round prep time each side may use, in seconds; 0 when the format has none. */ - prepSeconds: number; - /** Seconds past time a speech may run before judges stop listening, where the rules give one. */ - graceSeconds?: number; - sources: readonly FormatSource[]; - /** What varies between leagues, in one or two sentences. */ - note: string; -} - -export const BUILT_IN_FORMAT_IDS = ['pf', 'ld', 'policy', 'bp', 'wsdc', 'ap', 'school'] as const; -export type BuiltInFormatId = (typeof BUILT_IN_FORMAT_IDS)[number]; - -const MIN = 60; - -const NSDA_MANUAL: FormatSource = { - label: 'NSDA High School Unified Manual', - url: 'https://www.speechanddebate.org/high-school-unified-manual/', -}; - -const speech = (id: string, short: string, label: string, side: SegmentSide, seconds: number, poi?: RoundSegment['poi'], role?: string): RoundSegment => - ({ id, short, label, side, kind: 'speech', seconds, ...(poi ? { poi } : {}), ...(role ? { role } : {}) }); -const question = (id: string, short: string, label: string, side: SegmentSide, kind: 'cross-examination' | 'crossfire', seconds: number): RoundSegment => - ({ id, short, label, side, kind, seconds }); -const reply = (id: string, short: string, label: string, side: SegmentSide, seconds: number): RoundSegment => - ({ id, short, label, side, kind: 'reply', seconds, role: 'reply' }); - -/** POIs between the end of the first minute and the start of the last. */ -const protectedEnds = (seconds: number) => ({ opensAt: MIN, closesAt: seconds - MIN }); - -export const FORMATS: Record = { - pf: { - id: 'pf', - name: 'Public Forum', - shortName: 'PF', - sideNames: ['Team A', 'Team B'], - segments: [ - speech('a1', 'A Const', 'Team A constructive (first speaker)', 'a', 4 * MIN, undefined, 'constructive'), - speech('b1', 'B Const', 'Team B constructive (first speaker)', 'b', 4 * MIN, undefined, 'constructive'), - question('cf1', 'CF 1', 'Crossfire (first speakers)', 'both', 'crossfire', 3 * MIN), - speech('a2', 'A Rebuttal', 'Team A rebuttal (second speaker)', 'a', 4 * MIN, undefined, 'rebuttal'), - speech('b2', 'B Rebuttal', 'Team B rebuttal (second speaker)', 'b', 4 * MIN, undefined, 'rebuttal'), - question('cf2', 'CF 2', 'Crossfire (second speakers)', 'both', 'crossfire', 3 * MIN), - speech('a3', 'A Summary', 'Team A summary (first speaker)', 'a', 3 * MIN, undefined, 'summary'), - speech('b3', 'B Summary', 'Team B summary (first speaker)', 'b', 3 * MIN, undefined, 'summary'), - question('gcf', 'Grand CF', 'Grand crossfire (all four debaters)', 'both', 'crossfire', 3 * MIN), - speech('a4', 'A Final Focus', 'Team A final focus (second speaker)', 'a', 2 * MIN, undefined, 'final focus'), - speech('b4', 'B Final Focus', 'Team B final focus (second speaker)', 'b', 2 * MIN, undefined, 'final focus'), - ], - prepSeconds: 3 * MIN, - sources: [NSDA_MANUAL], - note: 'A coin toss decides which team speaks first, so Team A can be Pro or Con. NSDA moved summaries and prep from 2 to 3 minutes in 2020; some local leagues still use 2 and 2.', - }, - ld: { - id: 'ld', - name: 'Lincoln-Douglas', - shortName: 'LD', - sideNames: ['Affirmative', 'Negative'], - segments: [ - speech('ac', 'AC', 'Affirmative constructive', 'a', 6 * MIN), - question('cx1', 'CX', 'Negative cross-examines the Affirmative', 'b', 'cross-examination', 3 * MIN), - speech('nc', 'NC', 'Negative constructive', 'b', 7 * MIN), - question('cx2', 'CX', 'Affirmative cross-examines the Negative', 'a', 'cross-examination', 3 * MIN), - speech('1ar', '1AR', 'First affirmative rebuttal', 'a', 4 * MIN), - speech('nr', 'NR', 'Negative rebuttal', 'b', 6 * MIN), - speech('2ar', '2AR', 'Second affirmative rebuttal', 'a', 3 * MIN), - ], - prepSeconds: 4 * MIN, - sources: [NSDA_MANUAL], - note: 'Four minutes of prep per debater is the NSDA standard; some tournaments allow up to five, so check the invitation.', - }, - policy: { - id: 'policy', - name: 'Policy (CX)', - shortName: 'Policy', - sideNames: ['Affirmative', 'Negative'], - segments: [ - speech('1ac', '1AC', 'First affirmative constructive', 'a', 8 * MIN), - question('cx1', 'CX', 'Negative cross-examines the 1AC', 'b', 'cross-examination', 3 * MIN), - speech('1nc', '1NC', 'First negative constructive', 'b', 8 * MIN), - question('cx2', 'CX', 'Affirmative cross-examines the 1NC', 'a', 'cross-examination', 3 * MIN), - speech('2ac', '2AC', 'Second affirmative constructive', 'a', 8 * MIN), - question('cx3', 'CX', 'Negative cross-examines the 2AC', 'b', 'cross-examination', 3 * MIN), - speech('2nc', '2NC', 'Second negative constructive', 'b', 8 * MIN), - question('cx4', 'CX', 'Affirmative cross-examines the 2NC', 'a', 'cross-examination', 3 * MIN), - speech('1nr', '1NR', 'First negative rebuttal', 'b', 5 * MIN), - speech('1ar', '1AR', 'First affirmative rebuttal', 'a', 5 * MIN), - speech('2nr', '2NR', 'Second negative rebuttal', 'b', 5 * MIN), - speech('2ar', '2AR', 'Second affirmative rebuttal', 'a', 5 * MIN), - ], - prepSeconds: 8 * MIN, - sources: [NSDA_MANUAL], - note: 'This is the NSDA 8-3-5 structure with 8 minutes of prep per team. Older material lists 5 minutes of prep, and some state circuits still run 4-minute rebuttals.', - }, - bp: { - id: 'bp', - name: 'British Parliamentary', - shortName: 'BP', - sideNames: ['Government', 'Opposition'], - segments: [ - speech('pm', 'PM', 'Prime Minister (Opening Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('lo', 'LO', 'Leader of the Opposition (Opening Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('dpm', 'DPM', 'Deputy Prime Minister (Opening Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('dlo', 'DLO', 'Deputy Leader of the Opposition (Opening Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('mg', 'MG', 'Member of Government (Closing Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('mo', 'MO', 'Member of Opposition (Closing Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('gw', 'GW', 'Government Whip (Closing Government)', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('ow', 'OW', 'Opposition Whip (Closing Opposition)', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - ], - prepSeconds: 0, - graceSeconds: 15, - sources: [ - { label: 'WUDC Debating & Judging Manual (2024 revision)', url: 'https://debate.ethz.ch/wp-content/uploads/2024/11/WUDC-Debating-Judging-Manual-Revised-November-2024-.pdf' }, - ], - note: 'Teams get 15 minutes of preparation after the motion is released, before the round. Signals sound at 1:00, 6:00 and 7:00; judges disregard material after 7:15.', - }, - wsdc: { - id: 'wsdc', - name: 'World Schools', - shortName: 'WSDC', - sideNames: ['Proposition', 'Opposition'], - segments: [ - speech('p1', 'Prop 1', 'First Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - speech('o1', 'Opp 1', 'First Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - speech('p2', 'Prop 2', 'Second Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - speech('o2', 'Opp 2', 'Second Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - speech('p3', 'Prop 3', 'Third Proposition', 'a', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - speech('o3', 'Opp 3', 'Third Opposition', 'b', 8 * MIN, protectedEnds(8 * MIN), 'substantive speech'), - reply('or', 'Opp Reply', 'Opposition reply (first or second speaker)', 'b', 4 * MIN), - reply('pr', 'Prop Reply', 'Proposition reply (first or second speaker)', 'a', 4 * MIN), - ], - prepSeconds: 0, - sources: [ - { label: 'WSDC Tournament Committee & Debate Rules (August 2026)', url: 'https://www.wsdcdebating.org/_files/ugd/669183_8ee4b95d4bf4409a893cc22e5f1f35b9.pdf' }, - { label: 'NSDA USA World Schools Debate Invitational manual', url: 'https://docs.google.com/document/d/1w5t9sQhQGBoRQBUomjVgx-2vThAXctWDlU3oECzQ0-g' }, - ], - note: 'Reply speeches run in reverse order, Opposition first, and take no points of information. Impromptu motions come with one hour of preparation; prepared motions are released weeks ahead.', - }, - ap: { - id: 'ap', - name: 'Asian Parliamentary', - shortName: 'AP', - sideNames: ['Government', 'Opposition'], - segments: [ - speech('pm', 'PM', 'Prime Minister', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('lo', 'LO', 'Leader of the Opposition', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('dpm', 'DPM', 'Deputy Prime Minister', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('dlo', 'DLO', 'Deputy Leader of the Opposition', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('gw', 'GW', 'Government Whip', 'a', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - speech('ow', 'OW', 'Opposition Whip', 'b', 7 * MIN, protectedEnds(7 * MIN), 'substantive speech'), - reply('or', 'Opp Reply', 'Opposition reply (first or second speaker)', 'b', 4 * MIN), - reply('gr', 'Gov Reply', 'Government reply (first or second speaker)', 'a', 4 * MIN), - ], - prepSeconds: 0, - graceSeconds: 15, - sources: [ - { label: 'UADC 2023 Debating and Judging Handbook', url: 'https://ugc.production.linktr.ee/e32c9614-640c-4ce1-8522-3b613a228a89_Malaysia-UADC-2023---Debate---Judging-Handbook.pdf' }, - ], - note: 'There is no single official rulebook; this follows the UADC handbook, with 30 minutes of preparation before the round. Some school leagues shorten speeches, so check your tournament.', - }, - school: { - id: 'school', - name: 'School 4-6-6-3', - shortName: '4-6-6-3', - sideNames: ['Pro', 'Con'], - segments: [ - speech('pi', 'Pro Intro', 'Pro introductor', 'a', 4 * MIN, undefined, 'introductor'), - speech('ci', 'Con Intro', 'Con introductor', 'b', 4 * MIN, undefined, 'introductor'), - speech('pr1', 'Pro R1', 'Pro first rebuttal', 'a', 6 * MIN, undefined, 'first rebuttal'), - speech('cr1', 'Con R1', 'Con first rebuttal', 'b', 6 * MIN, undefined, 'first rebuttal'), - speech('pr2', 'Pro R2', 'Pro second rebuttal', 'a', 6 * MIN, undefined, 'second rebuttal'), - speech('cr2', 'Con R2', 'Con second rebuttal', 'b', 6 * MIN, undefined, 'second rebuttal'), - speech('pc', 'Pro Concl', 'Pro conclusor', 'a', 3 * MIN, undefined, 'conclusor'), - speech('cc', 'Con Concl', 'Con conclusor', 'b', 3 * MIN, undefined, 'conclusor'), - ], - prepSeconds: 0, - sources: [], - note: 'The school format DebateOwl uses for its own sparring rounds: introductor, two rebuttals and a conclusor per side. It is not a national league format.', - }, -}; - -export const BUILT_IN_FORMATS: readonly RoundFormat[] = BUILT_IN_FORMAT_IDS.map((id) => FORMATS[id]); - -export function isBuiltInFormatId(value: string | null | undefined): value is BuiltInFormatId { - return typeof value === 'string' && (BUILT_IN_FORMAT_IDS as readonly string[]).includes(value); -} - -/** The speeches a flow sheet gets a column for and a speech can be timed against: no questioning periods. */ -export function speakingSegments(format: RoundFormat): RoundSegment[] { - return format.segments.filter((segment) => segment.kind === 'speech' || segment.kind === 'reply'); -} - -export const segmentRole = (segment: RoundSegment) => segment.role ?? segment.short; - -export function questioningSegments(format: RoundFormat): RoundSegment[] { - return format.segments.filter((segment) => segment.kind === 'cross-examination' || segment.kind === 'crossfire'); -} - -export function roundSeconds(format: RoundFormat): number { - return format.segments.reduce((total, segment) => total + segment.seconds, 0) + 2 * format.prepSeconds; -} - -/** "4:00", "7:30", "1:00:00". */ -export function formatDuration(totalSeconds: number): string { - const seconds = Math.max(0, Math.round(totalSeconds)); - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const rest = String(seconds % 60).padStart(2, '0'); - return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`; -} +export * from '@debatelab/core/free-tools/formats'; diff --git a/src/lib/free-tools/motion-bank.ts b/src/lib/free-tools/motion-bank.ts index 42d1d8a68..677e0626c 100644 --- a/src/lib/free-tools/motion-bank.ts +++ b/src/lib/free-tools/motion-bank.ts @@ -1,96 +1 @@ -/** Filtering and random draws over the practice motion bank. */ -import { MOTION_DIFFICULTIES, MOTION_TOPICS, MOTION_TYPES, type MotionDifficulty, type MotionTopic, type MotionType, type PracticeMotion } from './motion-types'; - -export interface MotionFilters { - /** Empty means every topic; likewise for the others. */ - topics: MotionTopic[]; - difficulties: MotionDifficulty[]; - types: MotionType[]; -} - -export const NO_FILTERS: MotionFilters = { topics: [], difficulties: [], types: [] }; - -export type MotionWording = 'house' | 'resolved'; - -export const TOPIC_NAMES: Record = { - education: 'Education', - technology: 'Technology and AI', - environment: 'Environment', - economics: 'Economics', - politics: 'Politics and democracy', - health: 'Health', - sport: 'Sport', - culture: 'Culture and media', - ethics: 'Ethics', - international: 'International relations', - justice: 'Justice and law', - society: 'Society', -}; - -export function filterMotions(motions: readonly PracticeMotion[], filters: MotionFilters): PracticeMotion[] { - return motions.filter((motion) => - (filters.topics.length === 0 || filters.topics.includes(motion.topic)) && - (filters.difficulties.length === 0 || filters.difficulties.includes(motion.difficulty)) && - (filters.types.length === 0 || filters.types.includes(motion.type))); -} - -/** - * Up to `count` different motions, chosen uniformly. `exclude` keeps the ones - * just shown out of the next draw while enough others remain. - */ -export function drawMotions( - pool: readonly PracticeMotion[], - count: number, - random: () => number = Math.random, - exclude: readonly string[] = [], -): PracticeMotion[] { - const fresh = pool.filter((motion) => !exclude.includes(motion.id)); - const source = [...(fresh.length >= count ? fresh : pool)]; - const picked: PracticeMotion[] = []; - while (picked.length < count && source.length > 0) { - const index = Math.min(source.length - 1, Math.floor(random() * source.length)); - picked.push(source.splice(index, 1)[0]); - } - return picked; -} - -export function motionText(motion: PracticeMotion, wording: MotionWording): string { - return wording === 'resolved' ? motion.resolved : motion.motion; -} - -export function findMotion(motions: readonly PracticeMotion[], id: string | null | undefined): PracticeMotion | undefined { - return id ? motions.find((motion) => motion.id === id) : undefined; -} - -function listParam(value: string | null, allowed: readonly T[]): T[] { - if (!value) return []; - return [...new Set(value.split(',').filter((entry): entry is T => (allowed as readonly string[]).includes(entry)))]; -} - -/** Filters from `?topic=a,b&level=novice&type=policy`; unknown values are ignored. */ -export function filtersFromSearch(search: string): MotionFilters { - const params = new URLSearchParams(search); - return { - topics: listParam(params.get('topic'), MOTION_TOPICS), - difficulties: listParam(params.get('level'), MOTION_DIFFICULTIES), - types: listParam(params.get('type'), MOTION_TYPES), - }; -} - -/** The search string for a shown motion and the filters that drew it: `?m=edu-004&topic=education`. */ -export function searchFor(filters: MotionFilters, motionIds: readonly string[], wording: MotionWording): string { - const params = new URLSearchParams(); - if (motionIds.length > 0) params.set('m', motionIds.join(',')); - if (filters.topics.length) params.set('topic', filters.topics.join(',')); - if (filters.difficulties.length) params.set('level', filters.difficulties.join(',')); - if (filters.types.length) params.set('type', filters.types.join(',')); - if (wording === 'resolved') params.set('wording', 'resolved'); - const text = params.toString(); - return text ? `?${text}` : ''; -} - -/** The motions a permalink names, in order, skipping ids that do not exist; at most three. */ -export function motionsFromSearch(motions: readonly PracticeMotion[], search: string): PracticeMotion[] { - const ids = new URLSearchParams(search).get('m')?.split(',') ?? []; - return [...new Set(ids)].slice(0, 3).map((id) => findMotion(motions, id)).filter((motion): motion is PracticeMotion => Boolean(motion)); -} +export * from '@debatelab/core/free-tools/motion-bank'; diff --git a/src/lib/free-tools/motion-types.ts b/src/lib/free-tools/motion-types.ts index 3e89a0f36..a3a19c429 100644 --- a/src/lib/free-tools/motion-types.ts +++ b/src/lib/free-tools/motion-types.ts @@ -1,36 +1 @@ -export const MOTION_TOPICS = [ - 'education', - 'technology', - 'environment', - 'economics', - 'politics', - 'health', - 'sport', - 'culture', - 'ethics', - 'international', - 'justice', - 'society', -] as const; -export type MotionTopic = (typeof MOTION_TOPICS)[number]; - -export const MOTION_DIFFICULTIES = ['novice', 'intermediate', 'advanced'] as const; -export type MotionDifficulty = (typeof MOTION_DIFFICULTIES)[number]; - -/** policy "This House would", value "This House believes that", actor "This House, as X, would", regret, prefer. */ -export const MOTION_TYPES = ['policy', 'value', 'actor', 'regret', 'prefer'] as const; -export type MotionType = (typeof MOTION_TYPES)[number]; - -export interface PracticeMotion { - /** Stable permalink id (`?m=`): topic prefix and number, never renumbered. */ - id: string; - /** BP and World Schools wording: "This House would ...". */ - motion: string; - /** US-format wording: "Resolved: ...". */ - resolved: string; - topic: MotionTopic; - difficulty: MotionDifficulty; - type: MotionType; - /** Optional one-sentence context for an unfamiliar term; plain English. */ - infoSlide?: string; -} +export * from '@debatelab/core/free-tools/motion-types'; diff --git a/src/lib/free-tools/practice-motions.ts b/src/lib/free-tools/practice-motions.ts index 6adb8f547..0314524c2 100644 --- a/src/lib/free-tools/practice-motions.ts +++ b/src/lib/free-tools/practice-motions.ts @@ -1,2436 +1 @@ -import type { PracticeMotion } from './motion-types'; - -/** Original practice motions for the free motion generator. Ids are permalinks: never renumber or reuse them. */ -export const PRACTICE_MOTIONS: readonly PracticeMotion[] = [ - { - id: 'edu-001', - motion: 'This House would replace homework with extra reading time at school', - resolved: 'Resolved: Schools should replace homework with extra reading time during the school day.', - topic: 'education', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'edu-002', - motion: 'This House would let students grade their teachers at the end of every term', - resolved: 'Resolved: Students should grade their teachers at the end of every term.', - topic: 'education', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'edu-003', - motion: 'This House believes that school trips teach students as much as classroom lessons', - resolved: 'Resolved: School trips teach students as much as classroom lessons.', - topic: 'education', - difficulty: 'novice', - type: 'value', - }, - { - id: 'edu-004', - motion: 'This House, as a school principal, would let students choose where they sit in every class', - resolved: 'Resolved: School principals should let students choose where they sit in every class.', - topic: 'education', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'edu-005', - motion: 'This House regrets the tradition of end-of-year awards ceremonies in schools', - resolved: 'Resolved: End-of-year awards ceremonies in schools have done more harm than good.', - topic: 'education', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'edu-006', - motion: 'This House would make students lock their phones in sealed pouches for the whole school day', - resolved: 'Resolved: Students should be required to lock their phones in sealed pouches for the whole school day.', - topic: 'education', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'edu-007', - motion: 'This House would make cooking a required subject in every secondary school', - resolved: 'Resolved: Cooking should be a required subject in every secondary school.', - topic: 'education', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'edu-008', - motion: 'This House prefers a four-day school week with longer days to a five-day week with shorter days', - resolved: 'Resolved: A world in which schools run a four-day week with longer days is preferable to one in which they run a five-day week with shorter days.', - topic: 'education', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'edu-009', - motion: 'This House would replace letter grades with written feedback for all students under 16', - resolved: 'Resolved: Schools should replace letter grades with written feedback for all students under 16.', - topic: 'education', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'edu-010', - motion: 'This House would require every student to complete a work placement before leaving secondary school', - resolved: 'Resolved: Every student should complete a work placement before leaving secondary school.', - topic: 'education', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'edu-011', - motion: 'This House believes that financial literacy matters more than algebra for most students', - resolved: 'Resolved: Financial literacy matters more than algebra for most students.', - topic: 'education', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'edu-012', - motion: 'This House believes that schools should stay neutral on contested political issues', - resolved: 'Resolved: Schools ought to stay neutral on contested political issues.', - topic: 'education', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'edu-013', - motion: 'This House, as a selective university, would admit students by lottery from among all qualified applicants', - resolved: 'Resolved: Selective universities should admit students by lottery from among all qualified applicants.', - topic: 'education', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'edu-014', - motion: 'This House regrets school apps that show parents every grade the moment it is entered', - resolved: 'Resolved: School apps that show parents every grade the moment it is entered have done more harm than good.', - topic: 'education', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'edu-015', - motion: 'This House would pay students a small weekly sum for full attendance', - resolved: 'Resolved: Schools should pay students a small weekly sum for full attendance.', - topic: 'education', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'edu-016', - motion: 'This House prefers mixed-ability classes to classes sorted by ability', - resolved: 'Resolved: A world in which students learn in mixed-ability classes is preferable to one in which they are sorted into classes by ability.', - topic: 'education', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'edu-017', - motion: 'This House would replace university tuition fees with a graduate tax', - resolved: 'Resolved: University tuition fees should be replaced with a graduate tax.', - topic: 'education', - difficulty: 'intermediate', - type: 'policy', - infoSlide: 'A graduate tax is an extra tax paid only by people who went to university, charged as a share of their income after they graduate.', - }, - { - id: 'edu-018', - motion: 'This House would require elite universities to reserve half their places for applicants from the lowest-performing schools', - resolved: 'Resolved: Elite universities should reserve half their places for applicants from the lowest-performing schools.', - topic: 'education', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'edu-019', - motion: 'This House believes that the main purpose of education is to make good citizens rather than skilled workers', - resolved: 'Resolved: The main purpose of education ought to be making good citizens rather than skilled workers.', - topic: 'education', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'edu-020', - motion: 'This House, as a national education ministry, would fund schools by how much their pupils improve rather than by how many pupils they have', - resolved: 'Resolved: National education ministries should fund schools by how much their pupils improve rather than by how many pupils they have.', - topic: 'education', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'edu-021', - motion: 'This House regrets the global spread of university rankings', - resolved: 'Resolved: The global spread of university rankings has done more harm than good.', - topic: 'education', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'edu-022', - motion: 'This House believes that parents should be able to withdraw their children from any lesson that conflicts with their values', - resolved: 'Resolved: Parents ought to be able to withdraw their children from any lesson that conflicts with their values.', - topic: 'education', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'edu-023', - motion: 'This House believes that private schools are incompatible with a fair society', - resolved: 'Resolved: Private schools are incompatible with a fair society.', - topic: 'education', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'edu-024', - motion: 'This House, as a teachers\' union, would accept performance-related pay in exchange for higher base salaries', - resolved: 'Resolved: Teachers\' unions should accept performance-related pay in exchange for higher base salaries.', - topic: 'education', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'edu-025', - motion: 'This House would scrap high-stakes final exams at 18 in favor of assessment by students\' own teachers', - resolved: 'Resolved: High-stakes final exams at 18 should be scrapped in favor of assessment by students\' own teachers.', - topic: 'education', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'tech-001', - motion: 'This House would require every app to show users how many hours they spent on it each week', - resolved: 'Resolved: Every app should be required to show users how many hours they spent on it each week.', - topic: 'technology', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'tech-002', - motion: 'This House would keep smart speakers out of children\'s bedrooms', - resolved: 'Resolved: Smart speakers should be kept out of children\'s bedrooms.', - topic: 'technology', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'tech-003', - motion: 'This House believes that AI chatbots should always tell people they are not human', - resolved: 'Resolved: AI chatbots ought to always tell people that they are not human.', - topic: 'technology', - difficulty: 'novice', - type: 'value', - }, - { - id: 'tech-004', - motion: 'This House, as a parent, would track their teenager\'s location through their phone', - resolved: 'Resolved: Parents should track their teenagers\' locations through their phones.', - topic: 'technology', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'tech-005', - motion: 'This House prefers learning from a human tutor to learning from an AI tutor', - resolved: 'Resolved: A world in which students learn from human tutors is preferable to one in which they learn from AI tutors.', - topic: 'technology', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'tech-006', - motion: 'This House would allow delivery robots on city sidewalks', - resolved: 'Resolved: Delivery robots should be allowed on city sidewalks.', - topic: 'technology', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'tech-007', - motion: 'This House believes that it is wrong to use AI to write a personal message like a birthday card', - resolved: 'Resolved: It is wrong to use AI to write a personal message such as a birthday card.', - topic: 'technology', - difficulty: 'novice', - type: 'value', - }, - { - id: 'tech-008', - motion: 'This House would require every new phone to be repairable at home with ordinary tools', - resolved: 'Resolved: Every new phone should be repairable at home with ordinary tools.', - topic: 'technology', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'tech-009', - motion: 'This House would require all AI-generated images to carry a visible label', - resolved: 'Resolved: All AI-generated images should carry a visible label.', - topic: 'technology', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'tech-010', - motion: 'This House would ban shops from using facial recognition to identify customers', - resolved: 'Resolved: Shops should be banned from using facial recognition to identify customers.', - topic: 'technology', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'tech-011', - motion: 'This House believes that tech companies should be legally responsible for harm caused by their recommendation algorithms', - resolved: 'Resolved: Tech companies ought to be legally responsible for harm caused by their recommendation algorithms.', - topic: 'technology', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'tech-012', - motion: 'This House, as a leading AI company, would delay every new model until independent safety testers approve it', - resolved: 'Resolved: Leading AI companies should delay every new model until independent safety testers approve it.', - topic: 'technology', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'tech-013', - motion: 'This House regrets the replacement of human customer service staff with chatbots', - resolved: 'Resolved: The replacement of human customer service staff with chatbots has done more harm than good.', - topic: 'technology', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'tech-014', - motion: 'This House would give people legal ownership of their personal data, including the right to sell it', - resolved: 'Resolved: People should legally own their personal data, including the right to sell it.', - topic: 'technology', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'tech-015', - motion: 'This House prefers an internet where people post under their real names to one where they can stay anonymous', - resolved: 'Resolved: A world in which internet users post under their real names is preferable to one in which they can stay anonymous.', - topic: 'technology', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'tech-016', - motion: 'This House would tax companies for every job they replace with automation', - resolved: 'Resolved: Companies should pay a tax for every job they replace with automation.', - topic: 'technology', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'tech-017', - motion: 'This House believes that AI should never be used to grade student essays', - resolved: 'Resolved: AI ought never to be used to grade student essays.', - topic: 'technology', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'tech-018', - motion: 'This House would grant advanced AI systems legal personhood for the purposes of liability', - resolved: 'Resolved: Advanced AI systems should be granted legal personhood for the purposes of liability.', - topic: 'technology', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'Legal personhood means the law treats something as a person that can own property, sign contracts, and be sued, as it already does for companies.', - }, - { - id: 'tech-019', - motion: 'This House believes that end-to-end encryption should be protected even when it shields criminals', - resolved: 'Resolved: End-to-end encryption ought to be protected even when it shields criminals.', - topic: 'technology', - difficulty: 'advanced', - type: 'value', - infoSlide: 'End-to-end encryption scrambles messages so that only the sender and receiver can read them, not even the company running the service.', - }, - { - id: 'tech-020', - motion: 'This House, as a national government, would build its own public AI model rather than rely on private companies', - resolved: 'Resolved: National governments should build their own public AI models rather than rely on private companies.', - topic: 'technology', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'tech-021', - motion: 'This House regrets the concentration of cutting-edge AI development in a handful of private companies', - resolved: 'Resolved: The concentration of cutting-edge AI development in a handful of private companies has done more harm than good.', - topic: 'technology', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'tech-022', - motion: 'This House would require AI models trained on publicly available data to be released as open source', - resolved: 'Resolved: AI models trained on publicly available data should be released as open source.', - topic: 'technology', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'Open source means the code or model is published so that anyone can inspect, use, and change it.', - }, - { - id: 'tech-023', - motion: 'This House believes that humanity should deliberately slow down the development of advanced AI', - resolved: 'Resolved: Humanity ought to deliberately slow down the development of advanced AI.', - topic: 'technology', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'tech-024', - motion: 'This House, as a space agency, would prioritize robotic missions over sending humans to Mars', - resolved: 'Resolved: Space agencies should prioritize robotic missions over sending humans to Mars.', - topic: 'technology', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'tech-025', - motion: 'This House prefers AI that only advises human decision-makers to AI that makes high-stakes decisions on its own', - resolved: 'Resolved: A world in which AI only advises human decision-makers is preferable to one in which AI makes high-stakes decisions on its own.', - topic: 'technology', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'env-001', - motion: 'This House would put a refundable deposit on every drink can and bottle', - resolved: 'Resolved: Every drink can and bottle should carry a refundable deposit.', - topic: 'environment', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'env-002', - motion: 'This House would close the streets outside schools to cars at drop-off and pick-up times', - resolved: 'Resolved: Streets outside schools should be closed to cars at drop-off and pick-up times.', - topic: 'environment', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'env-003', - motion: 'This House believes that grass lawns are a waste of space', - resolved: 'Resolved: Grass lawns are a waste of space.', - topic: 'environment', - difficulty: 'novice', - type: 'value', - }, - { - id: 'env-004', - motion: 'This House, as a city mayor, would turn half of the city\'s parking spaces into parks', - resolved: 'Resolved: City mayors should turn half of their city\'s parking spaces into parks.', - topic: 'environment', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'env-005', - motion: 'This House regrets the rise of ultra-cheap clothing sold online', - resolved: 'Resolved: The rise of ultra-cheap clothing sold online has done more harm than good.', - topic: 'environment', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'env-006', - motion: 'This House would charge households for trash collection by the weight of what they throw away', - resolved: 'Resolved: Households should pay for trash collection by the weight of what they throw away.', - topic: 'environment', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'env-007', - motion: 'This House believes that keeping pets is bad for the planet', - resolved: 'Resolved: Keeping pets is bad for the planet.', - topic: 'environment', - difficulty: 'novice', - type: 'value', - }, - { - id: 'env-008', - motion: 'This House prefers buying local food to buying the cheapest food available', - resolved: 'Resolved: A world in which people buy local food is preferable to one in which they buy the cheapest food available.', - topic: 'environment', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'env-009', - motion: 'This House would tax meat according to its carbon footprint', - resolved: 'Resolved: Meat should be taxed according to its carbon footprint.', - topic: 'environment', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'env-010', - motion: 'This House would ban flights on routes where the same trip takes under five hours by train', - resolved: 'Resolved: Flights should be banned on routes where the same trip takes under five hours by train.', - topic: 'environment', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'env-011', - motion: 'This House believes that nuclear power is essential to fighting climate change', - resolved: 'Resolved: Nuclear power is essential to fighting climate change.', - topic: 'environment', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'env-012', - motion: 'This House, as an environmental group, would use disruptive protests such as blocking roads', - resolved: 'Resolved: Environmental groups should use disruptive protests such as blocking roads.', - topic: 'environment', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'env-013', - motion: 'This House regrets the rise of carbon offsetting', - resolved: 'Resolved: The rise of carbon offsetting has done more harm than good.', - topic: 'environment', - difficulty: 'intermediate', - type: 'regret', - infoSlide: 'Carbon offsetting means paying for a project, such as planting trees, that is meant to cancel out the greenhouse gases you produced.', - }, - { - id: 'env-014', - motion: 'This House would let citizens sue their government for missing its own climate targets', - resolved: 'Resolved: Citizens should be able to sue their government for missing its own climate targets.', - topic: 'environment', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'env-015', - motion: 'This House would require solar panels on every new home', - resolved: 'Resolved: Every new home should be required to have solar panels.', - topic: 'environment', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'env-016', - motion: 'This House believes that pressure on individuals to live green distracts from holding big polluters to account', - resolved: 'Resolved: Pressure on individuals to live green distracts from holding big polluters to account.', - topic: 'environment', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'env-017', - motion: 'This House would pay farmers to rewild a fifth of their land', - resolved: 'Resolved: Farmers should be paid to rewild a fifth of their land.', - topic: 'environment', - difficulty: 'intermediate', - type: 'policy', - infoSlide: 'Rewilding means letting land return to a natural state, often by bringing back native plants and animals.', - }, - { - id: 'env-018', - motion: 'This House would impose a carbon border tax on imported goods', - resolved: 'Resolved: Countries should impose a carbon border tax on imported goods.', - topic: 'environment', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'A carbon border tax is a charge on imported goods based on how much greenhouse gas was released in making them.', - }, - { - id: 'env-019', - motion: 'This House believes that rich countries should pursue degrowth', - resolved: 'Resolved: Rich countries ought to pursue degrowth.', - topic: 'environment', - difficulty: 'advanced', - type: 'value', - infoSlide: 'Degrowth is the idea that rich countries should deliberately shrink production and consumption to protect the environment.', - }, - { - id: 'env-020', - motion: 'This House, as a developing country, would refuse further emissions cuts until wealthy countries pay climate compensation', - resolved: 'Resolved: Developing countries should refuse further emissions cuts until wealthy countries pay climate compensation.', - topic: 'environment', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'env-021', - motion: 'This House regrets the framing of climate change as an emergency', - resolved: 'Resolved: Framing climate change as an emergency has done more harm than good.', - topic: 'environment', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'env-022', - motion: 'This House would fund large-scale trials of solar geoengineering', - resolved: 'Resolved: Governments should fund large-scale trials of solar geoengineering.', - topic: 'environment', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'Solar geoengineering means deliberately reflecting some sunlight away from Earth, for example with particles released high in the atmosphere, to cool the planet.', - }, - { - id: 'env-023', - motion: 'This House believes that the interests of future generations should count as much as those of people alive today', - resolved: 'Resolved: The interests of future generations ought to count as much as those of people alive today.', - topic: 'environment', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'env-024', - motion: 'This House, as a major oil-producing country, would leave its reserves in the ground in exchange for international payments', - resolved: 'Resolved: Major oil-producing countries should leave their reserves in the ground in exchange for international payments.', - topic: 'environment', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'env-025', - motion: 'This House prefers protecting a few large wilderness areas to protecting many small green spaces near where people live', - resolved: 'Resolved: A world in which conservation protects a few large wilderness areas is preferable to one in which it protects many small green spaces near where people live.', - topic: 'environment', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'econ-001', - motion: 'This House would let children under 16 earn money from part-time jobs only with school approval', - resolved: 'Resolved: Children under 16 should be allowed to earn money from part-time jobs only with school approval.', - topic: 'economics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'econ-002', - motion: 'This House would require supermarkets to give unsold food to charities instead of throwing it away', - resolved: 'Resolved: Supermarkets should be required to give unsold food to charities instead of throwing it away.', - topic: 'economics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'econ-003', - motion: 'This House believes that children should earn their allowance by doing chores', - resolved: 'Resolved: Children ought to earn their allowance by doing chores.', - topic: 'economics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'econ-004', - motion: 'This House, as a small business owner, would pay all staff the same wage regardless of their role', - resolved: 'Resolved: Small business owners should pay all staff the same wage regardless of their role.', - topic: 'economics', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'econ-005', - motion: 'This House prefers a world where people mostly pay with cash to one where people mostly pay by card or phone', - resolved: 'Resolved: A world in which people mostly pay with cash is preferable to one in which they mostly pay by card or phone.', - topic: 'economics', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'econ-006', - motion: 'This House would ban buy-now-pay-later offers for shoppers under 21', - resolved: 'Resolved: Buy-now-pay-later offers should be banned for shoppers under 21.', - topic: 'economics', - difficulty: 'novice', - type: 'policy', - infoSlide: 'Buy-now-pay-later lets shoppers take a product today and pay for it in installments later, often without a full credit check.', - }, - { - id: 'econ-007', - motion: 'This House believes that tipping in restaurants should be replaced by higher wages', - resolved: 'Resolved: Tipping in restaurants ought to be replaced by higher wages.', - topic: 'economics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'econ-008', - motion: 'This House regrets the rise of subscriptions for things people used to buy once', - resolved: 'Resolved: The rise of subscriptions for things people used to buy once has done more harm than good.', - topic: 'economics', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'econ-009', - motion: 'This House would introduce a four-day working week with no cut in pay', - resolved: 'Resolved: Employers should introduce a four-day working week with no cut in pay.', - topic: 'economics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'econ-010', - motion: 'This House would require app-based delivery and ride companies to treat their drivers as employees', - resolved: 'Resolved: App-based delivery and ride companies should be required to treat their drivers as employees.', - topic: 'economics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'econ-011', - motion: 'This House believes that inheritance is unfair', - resolved: 'Resolved: Inheritance is unfair.', - topic: 'economics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'econ-012', - motion: 'This House, as a city council, would cap rent increases on all private housing', - resolved: 'Resolved: City councils should cap rent increases on all private housing.', - topic: 'economics', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'econ-013', - motion: 'This House would tax large online retailers to fund struggling local shopping streets', - resolved: 'Resolved: Large online retailers should be taxed to fund struggling local shopping streets.', - topic: 'economics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'econ-014', - motion: 'This House would cap the pay of company bosses at twenty times that of their lowest-paid worker', - resolved: 'Resolved: The pay of company bosses should be capped at twenty times that of their lowest-paid worker.', - topic: 'economics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'econ-015', - motion: 'This House prefers a society that taxes wealth to one that taxes income', - resolved: 'Resolved: A world in which governments tax wealth is preferable to one in which they tax income.', - topic: 'economics', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'econ-016', - motion: 'This House would give every citizen a lump sum of money on their 18th birthday', - resolved: 'Resolved: Every citizen should receive a lump sum of money on their 18th birthday.', - topic: 'economics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'econ-017', - motion: 'This House believes that billionaires are a sign of a failing economy', - resolved: 'Resolved: The existence of billionaires is a sign of a failing economy.', - topic: 'economics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'econ-018', - motion: 'This House would replace property taxes with a tax on the value of land alone', - resolved: 'Resolved: Property taxes should be replaced with a tax on the value of land alone.', - topic: 'economics', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'A land value tax charges owners based on the value of the land itself, not on any buildings on it.', - }, - { - id: 'econ-019', - motion: 'This House would give workers half of the seats on the boards of large companies', - resolved: 'Resolved: Workers should hold half of the seats on the boards of large companies.', - topic: 'economics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'econ-020', - motion: 'This House, as a central bank, would launch a digital currency that citizens hold directly with it', - resolved: 'Resolved: Central banks should launch digital currencies that citizens hold directly with them.', - topic: 'economics', - difficulty: 'advanced', - type: 'actor', - infoSlide: 'A central bank manages a country\'s money supply and interest rates; a central bank digital currency would be electronic cash issued by it directly.', - }, - { - id: 'econ-021', - motion: 'This House regrets the independence of central banks from elected governments', - resolved: 'Resolved: The independence of central banks from elected governments has done more harm than good.', - topic: 'economics', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'econ-022', - motion: 'This House believes that economic growth should no longer be the main goal of government policy', - resolved: 'Resolved: Economic growth ought no longer to be the main goal of government policy.', - topic: 'economics', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'econ-023', - motion: 'This House, as a developing country, would protect its young industries with high tariffs', - resolved: 'Resolved: Developing countries should protect their young industries with high tariffs.', - topic: 'economics', - difficulty: 'advanced', - type: 'actor', - infoSlide: 'A tariff is a tax on goods brought in from other countries.', - }, - { - id: 'econ-024', - motion: 'This House believes that companies should serve all their stakeholders, not just their shareholders', - resolved: 'Resolved: Companies ought to serve all their stakeholders, not just their shareholders.', - topic: 'economics', - difficulty: 'advanced', - type: 'value', - infoSlide: 'Stakeholders are everyone affected by a company, such as workers, customers, and local communities, while shareholders are the people who own it.', - }, - { - id: 'econ-025', - motion: 'This House prefers a jobs guarantee to unconditional cash payments', - resolved: 'Resolved: A world in which the government guarantees everyone a job is preferable to one in which it gives everyone unconditional cash payments.', - topic: 'economics', - difficulty: 'advanced', - type: 'prefer', - infoSlide: 'A jobs guarantee means the government promises paid work to anyone who wants it but cannot find a job.', - }, - { - id: 'pol-001', - motion: 'This House would let 16-year-olds vote in local elections but not national ones', - resolved: 'Resolved: Sixteen-year-olds should be able to vote in local elections but not national ones.', - topic: 'politics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'pol-002', - motion: 'This House would make election day a public holiday', - resolved: 'Resolved: Election day should be a public holiday.', - topic: 'politics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'pol-003', - motion: 'This House believes that politicians should be required to have had a job outside politics', - resolved: 'Resolved: Politicians ought to be required to have had a job outside politics.', - topic: 'politics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'pol-004', - motion: 'This House, as a student council, would hold a whole-school vote before every major decision', - resolved: 'Resolved: Student councils should hold a whole-school vote before every major decision.', - topic: 'politics', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'pol-005', - motion: 'This House regrets the use of attack ads in election campaigns', - resolved: 'Resolved: The use of attack ads in election campaigns has done more harm than good.', - topic: 'politics', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'pol-006', - motion: 'This House would let people vote from their phones', - resolved: 'Resolved: People should be able to vote from their phones.', - topic: 'politics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'pol-007', - motion: 'This House believes that no politician should serve more than two terms in the same office', - resolved: 'Resolved: No politician ought to serve more than two terms in the same office.', - topic: 'politics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'pol-008', - motion: 'This House prefers leaders chosen for their experience to leaders chosen for their fresh ideas', - resolved: 'Resolved: A world in which voters choose leaders for their experience is preferable to one in which they choose leaders for their fresh ideas.', - topic: 'politics', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'pol-009', - motion: 'This House would make voting compulsory', - resolved: 'Resolved: Voting should be compulsory.', - topic: 'politics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'pol-010', - motion: 'This House would let citizens recall their elected representatives through a petition', - resolved: 'Resolved: Citizens should be able to recall their elected representatives through a petition.', - topic: 'politics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'pol-011', - motion: 'This House believes that referendums do more to damage democracy than strengthen it', - resolved: 'Resolved: Referendums do more to damage democracy than to strengthen it.', - topic: 'politics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'pol-012', - motion: 'This House, as a newly elected mayor, would let residents decide directly how a tenth of the city budget is spent', - resolved: 'Resolved: Newly elected mayors should let residents decide directly how a tenth of the city budget is spent.', - topic: 'politics', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'pol-013', - motion: 'This House regrets the rise of celebrity politicians', - resolved: 'Resolved: The rise of celebrity politicians has done more harm than good.', - topic: 'politics', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'pol-014', - motion: 'This House would ban private donations to political parties and fund them from public money', - resolved: 'Resolved: Private donations to political parties should be banned and parties funded from public money.', - topic: 'politics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'pol-015', - motion: 'This House would set aside seats in parliament for representatives under 30', - resolved: 'Resolved: Seats in national legislatures should be set aside for representatives under 30.', - topic: 'politics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'pol-016', - motion: 'This House believes that politicians should vote according to their conscience rather than what their voters want', - resolved: 'Resolved: Politicians ought to vote according to their conscience rather than what their voters want.', - topic: 'politics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'pol-017', - motion: 'This House prefers coalition governments to single-party governments', - resolved: 'Resolved: A world in which countries are run by coalition governments is preferable to one in which they are run by single-party governments.', - topic: 'politics', - difficulty: 'intermediate', - type: 'prefer', - infoSlide: 'A coalition government is formed when two or more parties agree to govern together because none has a majority alone.', - }, - { - id: 'pol-018', - motion: 'This House would replace an elected upper chamber with an assembly of randomly selected citizens', - resolved: 'Resolved: An elected upper chamber should be replaced with an assembly of randomly selected citizens.', - topic: 'politics', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'An upper chamber is the second house of a parliament, such as a senate, which reviews laws passed by the main house.', - }, - { - id: 'pol-019', - motion: 'This House believes that democracy is best protected by limiting what a simple majority can change', - resolved: 'Resolved: Democracy is best protected by limiting what a simple majority can change.', - topic: 'politics', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'pol-020', - motion: 'This House, as an opposition party, would cooperate with the government on major national issues rather than oppose it', - resolved: 'Resolved: Opposition parties should cooperate with the government on major national issues rather than oppose it.', - topic: 'politics', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'pol-021', - motion: 'This House regrets the rise of single-issue political movements', - resolved: 'Resolved: The rise of single-issue political movements has done more harm than good.', - topic: 'politics', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'pol-022', - motion: 'This House would give parents an extra vote for each child below voting age', - resolved: 'Resolved: Parents should receive an extra vote for each child below voting age.', - topic: 'politics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'pol-023', - motion: 'This House believes that the state should actively promote a shared national identity', - resolved: 'Resolved: The state ought to actively promote a shared national identity.', - topic: 'politics', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'pol-024', - motion: 'This House, as a regional government seeking independence, would hold a referendum without the national government\'s consent', - resolved: 'Resolved: Regional governments seeking independence should hold referendums without the national government\'s consent.', - topic: 'politics', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'pol-025', - motion: 'This House would let independent experts, not elected ministers, set national budget targets', - resolved: 'Resolved: Independent experts, not elected ministers, should set national budget targets.', - topic: 'politics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'health-001', - motion: 'This House would ban energy drinks for anyone under 16', - resolved: 'Resolved: Energy drinks should be banned for anyone under 16.', - topic: 'health', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'health-002', - motion: 'This House would start the school day no earlier than 9:30 to let teenagers sleep', - resolved: 'Resolved: The school day should start no earlier than 9:30 to let teenagers sleep.', - topic: 'health', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'health-003', - motion: 'This House believes that fitness trackers do more to stress people than to help them', - resolved: 'Resolved: Fitness trackers do more to stress people than to help them.', - topic: 'health', - difficulty: 'novice', - type: 'value', - }, - { - id: 'health-004', - motion: 'This House, as a school cafeteria, would serve only vegetarian meals one week in every month', - resolved: 'Resolved: School cafeterias should serve only vegetarian meals one week in every month.', - topic: 'health', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'health-005', - motion: 'This House prefers team sports to individual exercise as the main part of school PE', - resolved: 'Resolved: A world in which team sports are the main part of school PE is preferable to one in which individual exercise is.', - topic: 'health', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'health-006', - motion: 'This House would put warning labels on sugary snacks like those on cigarettes', - resolved: 'Resolved: Sugary snacks should carry warning labels like those on cigarettes.', - topic: 'health', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'health-007', - motion: 'This House would give every student a mental health day off each term', - resolved: 'Resolved: Every student should get a mental health day off each term.', - topic: 'health', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'health-008', - motion: 'This House believes that influencers who give diet advice should be held to the same standards as qualified nutritionists', - resolved: 'Resolved: Influencers who give diet advice ought to be held to the same standards as qualified nutritionists.', - topic: 'health', - difficulty: 'novice', - type: 'value', - }, - { - id: 'health-009', - motion: 'This House would make organ donation opt-out rather than opt-in', - resolved: 'Resolved: Organ donation should be opt-out rather than opt-in.', - topic: 'health', - difficulty: 'intermediate', - type: 'policy', - infoSlide: 'In an opt-out system everyone is treated as a willing organ donor after death unless they register a refusal.', - }, - { - id: 'health-010', - motion: 'This House would ban advertising of junk food anywhere children are likely to see it', - resolved: 'Resolved: Junk food advertising should be banned anywhere children are likely to see it.', - topic: 'health', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'health-011', - motion: 'This House believes that people who choose unhealthy lifestyles should pay more for their healthcare', - resolved: 'Resolved: People who choose unhealthy lifestyles ought to pay more for their healthcare.', - topic: 'health', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'health-012', - motion: 'This House, as a large employer, would offer staff cash rewards for meeting health goals', - resolved: 'Resolved: Large employers should offer staff cash rewards for meeting health goals.', - topic: 'health', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'health-013', - motion: 'This House regrets the use of AI symptom checkers in place of a first visit to a doctor', - resolved: 'Resolved: The use of AI symptom checkers in place of a first visit to a doctor has done more harm than good.', - topic: 'health', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'health-014', - motion: 'This House would raise the legal age for buying tobacco by one year every year', - resolved: 'Resolved: The legal age for buying tobacco should rise by one year every year.', - topic: 'health', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'health-015', - motion: 'This House prefers spending health budgets on prevention to spending them on treatment', - resolved: 'Resolved: A world in which health budgets are spent mainly on prevention is preferable to one in which they are spent mainly on treatment.', - topic: 'health', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'health-016', - motion: 'This House would let pharmacists prescribe common medicines without a doctor\'s appointment', - resolved: 'Resolved: Pharmacists should be able to prescribe common medicines without a doctor\'s appointment.', - topic: 'health', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'health-017', - motion: 'This House believes that wellness culture does more harm than good to young people', - resolved: 'Resolved: Wellness culture does more harm than good to young people.', - topic: 'health', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'health-018', - motion: 'This House would let governments override drug patents during public health emergencies', - resolved: 'Resolved: Governments should be able to override drug patents during public health emergencies.', - topic: 'health', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'A drug patent gives the company that invented a medicine the sole right to make and sell it for a number of years.', - }, - { - id: 'health-019', - motion: 'This House would pay people to donate a kidney through a regulated public scheme', - resolved: 'Resolved: People should be paid to donate a kidney through a regulated public scheme.', - topic: 'health', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'health-020', - motion: 'This House, as a public health system, would ration expensive treatments using cost-per-year-of-life thresholds', - resolved: 'Resolved: Public health systems should ration expensive treatments using cost-per-year-of-life thresholds.', - topic: 'health', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'health-021', - motion: 'This House regrets the medicalization of ordinary sadness', - resolved: 'Resolved: The medicalization of ordinary sadness has done more harm than good.', - topic: 'health', - difficulty: 'advanced', - type: 'regret', - infoSlide: 'Medicalization means treating a normal human experience as a medical condition that needs diagnosis or treatment.', - }, - { - id: 'health-022', - motion: 'This House believes that individual liberty should give way to public health in a pandemic', - resolved: 'Resolved: Individual liberty ought to give way to public health in a pandemic.', - topic: 'health', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'health-023', - motion: 'This House believes that doctors should be free to refuse treatments that conflict with their personal beliefs', - resolved: 'Resolved: Doctors ought to be free to refuse treatments that conflict with their personal beliefs.', - topic: 'health', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'health-024', - motion: 'This House, as a pharmaceutical company, would price medicines according to each country\'s wealth', - resolved: 'Resolved: Pharmaceutical companies should price medicines according to each country\'s wealth.', - topic: 'health', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'health-025', - motion: 'This House prefers a single public health system to a system of competing private insurers', - resolved: 'Resolved: A world in which healthcare is run by a single public system is preferable to one in which it is run by competing private insurers.', - topic: 'health', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'sport-001', - motion: 'This House would stop keeping score in sports matches for children under 10', - resolved: 'Resolved: Sports matches for children under 10 should not keep score.', - topic: 'sport', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'sport-002', - motion: 'This House would let girls and boys play on the same school sports teams', - resolved: 'Resolved: Girls and boys should play on the same school sports teams.', - topic: 'sport', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'sport-003', - motion: 'This House believes that esports should count as real sports', - resolved: 'Resolved: Esports should count as real sports.', - topic: 'sport', - difficulty: 'novice', - type: 'value', - infoSlide: 'Esports are organized, competitive video game tournaments, often played by professional teams.', - }, - { - id: 'sport-004', - motion: 'This House, as a youth sports coach, would give every player equal playing time', - resolved: 'Resolved: Youth sports coaches should give every player equal playing time.', - topic: 'sport', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'sport-005', - motion: 'This House regrets the use of video replay to review referee decisions', - resolved: 'Resolved: The use of video replay to review referee decisions has done more harm than good.', - topic: 'sport', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'sport-006', - motion: 'This House would ban parents from shouting at youth sports games', - resolved: 'Resolved: Parents should be banned from shouting at youth sports games.', - topic: 'sport', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'sport-007', - motion: 'This House believes that winning matters too much in school sport', - resolved: 'Resolved: Winning matters too much in school sport.', - topic: 'sport', - difficulty: 'novice', - type: 'value', - }, - { - id: 'sport-008', - motion: 'This House prefers watching sport in person to watching it on screen', - resolved: 'Resolved: A world in which fans watch sport in person is preferable to one in which they watch it on screen.', - topic: 'sport', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'sport-009', - motion: 'This House would cap the amount any club can spend on player wages', - resolved: 'Resolved: Professional sports clubs should have a cap on how much they can spend on player wages.', - topic: 'sport', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'sport-010', - motion: 'This House would ban heading the ball in youth soccer', - resolved: 'Resolved: Heading the ball should be banned in youth soccer.', - topic: 'sport', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'sport-011', - motion: 'This House believes that athletes have a duty to speak out on social issues', - resolved: 'Resolved: Athletes have a duty to speak out on social issues.', - topic: 'sport', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'sport-012', - motion: 'This House, as a professional sports club, would hand majority ownership to its fans', - resolved: 'Resolved: Professional sports clubs should hand majority ownership to their fans.', - topic: 'sport', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'sport-013', - motion: 'This House regrets the rise of sports betting advertising', - resolved: 'Resolved: The rise of sports betting advertising has done more harm than good.', - topic: 'sport', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'sport-014', - motion: 'This House would require professional leagues to share television money equally between all their clubs', - resolved: 'Resolved: Professional leagues should share television money equally between all their clubs.', - topic: 'sport', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'sport-015', - motion: 'This House would require that major sporting events be shown on free-to-air television', - resolved: 'Resolved: Major sporting events should be shown on free-to-air television.', - topic: 'sport', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'sport-016', - motion: 'This House believes that governments should not spend public money on stadiums for professional teams', - resolved: 'Resolved: Governments ought not to spend public money on stadiums for professional teams.', - topic: 'sport', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'sport-017', - motion: 'This House prefers sports leagues with promotion and relegation to closed leagues with fixed members', - resolved: 'Resolved: A world in which sports leagues use promotion and relegation is preferable to one in which they are closed leagues with fixed members.', - topic: 'sport', - difficulty: 'intermediate', - type: 'prefer', - infoSlide: 'Promotion and relegation means the best teams in a lower league move up and the worst teams in a higher league move down each season.', - }, - { - id: 'sport-018', - motion: 'This House would allow performance-enhancing drugs in professional sport under medical supervision', - resolved: 'Resolved: Performance-enhancing drugs should be allowed in professional sport under medical supervision.', - topic: 'sport', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'sport-019', - motion: 'This House believes that it is legitimate for athletes to switch national teams for better opportunities', - resolved: 'Resolved: It is legitimate for athletes to switch national teams for better opportunities.', - topic: 'sport', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'sport-020', - motion: 'This House, as the International Olympic Committee, would hold every Summer Games in one permanent host city', - resolved: 'Resolved: The International Olympic Committee should hold every Summer Games in one permanent host city.', - topic: 'sport', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'sport-021', - motion: 'This House regrets the use of data analytics to make decisions in professional sport', - resolved: 'Resolved: The use of data analytics to make decisions in professional sport has done more harm than good.', - topic: 'sport', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'sport-022', - motion: 'This House would allow athletes with prosthetic or technological enhancements to compete against everyone else', - resolved: 'Resolved: Athletes with prosthetic or technological enhancements should compete against everyone else.', - topic: 'sport', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'sport-023', - motion: 'This House believes that hosting a major sporting event is rarely worth the cost to a country', - resolved: 'Resolved: Hosting a major sporting event is rarely worth the cost to a country.', - topic: 'sport', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'sport-024', - motion: 'This House, as a national sports body, would fund only sports where the country can win medals', - resolved: 'Resolved: National sports bodies should fund only sports where the country can win medals.', - topic: 'sport', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'sport-025', - motion: 'This House would require sports governing bodies to be overseen by an independent public regulator', - resolved: 'Resolved: Sports governing bodies should be overseen by an independent public regulator.', - topic: 'sport', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'cult-001', - motion: 'This House would make museums free for everyone', - resolved: 'Resolved: Museums should be free for everyone.', - topic: 'culture', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'cult-002', - motion: 'This House would require radio stations to play a set share of music by local artists', - resolved: 'Resolved: Radio stations should be required to play a set share of music by local artists.', - topic: 'culture', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'cult-003', - motion: 'This House believes that film adaptations should stay faithful to the books they are based on', - resolved: 'Resolved: Film adaptations ought to stay faithful to the books they are based on.', - topic: 'culture', - difficulty: 'novice', - type: 'value', - }, - { - id: 'cult-004', - motion: 'This House, as a film studio, would stop making sequels and remakes for five years', - resolved: 'Resolved: Film studios should stop making sequels and remakes for five years.', - topic: 'culture', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'cult-005', - motion: 'This House prefers music played by live musicians to music made with computers', - resolved: 'Resolved: A world in which most music is played by live musicians is preferable to one in which most music is made with computers.', - topic: 'culture', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'cult-006', - motion: 'This House would let street artists paint legally on chosen public walls', - resolved: 'Resolved: Street artists should be able to paint legally on chosen public walls.', - topic: 'culture', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'cult-007', - motion: 'This House believes that video games are a form of art', - resolved: 'Resolved: Video games are a form of art.', - topic: 'culture', - difficulty: 'novice', - type: 'value', - }, - { - id: 'cult-008', - motion: 'This House regrets the rise of short-form video', - resolved: 'Resolved: The rise of short-form video has done more harm than good.', - topic: 'culture', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'cult-009', - motion: 'This House would return cultural artifacts in museums to the countries they were taken from', - resolved: 'Resolved: Museums should return cultural artifacts to the countries they were taken from.', - topic: 'culture', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'cult-010', - motion: 'This House would ban AI-generated art from art competitions', - resolved: 'Resolved: AI-generated art should be banned from art competitions.', - topic: 'culture', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'cult-011', - motion: 'This House believes that an artist\'s personal conduct should not affect how their work is judged', - resolved: 'Resolved: An artist\'s personal conduct ought not to affect how their work is judged.', - topic: 'culture', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'cult-012', - motion: 'This House, as a streaming service, would show fewer recommendations and let users browse more', - resolved: 'Resolved: Streaming services should show fewer recommendations and let users browse more.', - topic: 'culture', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'cult-013', - motion: 'This House would ban children from appearing in money-making family vlogs', - resolved: 'Resolved: Children should be banned from appearing in money-making family vlogs.', - topic: 'culture', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'cult-014', - motion: 'This House would publicly fund artists with a basic monthly wage', - resolved: 'Resolved: Artists should receive a publicly funded basic monthly wage.', - topic: 'culture', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'cult-015', - motion: 'This House prefers a culture of owning books, music, and films to one of streaming them', - resolved: 'Resolved: A world in which people own their books, music, and films is preferable to one in which they stream them.', - topic: 'culture', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'cult-016', - motion: 'This House would edit old children\'s books to remove outdated language', - resolved: 'Resolved: Publishers should edit old children\'s books to remove outdated language.', - topic: 'culture', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'cult-017', - motion: 'This House believes that celebrities should keep their opinions on politics to themselves', - resolved: 'Resolved: Celebrities ought to keep their opinions on politics to themselves.', - topic: 'culture', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'cult-018', - motion: 'This House would shorten copyright so that creative works enter the public domain ten years after release', - resolved: 'Resolved: Copyright should be shortened so that creative works enter the public domain ten years after release.', - topic: 'culture', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'A work in the public domain is no longer protected by copyright, so anyone may copy, share, or adapt it for free.', - }, - { - id: 'cult-019', - motion: 'This House believes that cultural appropriation is a meaningful harm', - resolved: 'Resolved: Cultural appropriation is a meaningful harm.', - topic: 'culture', - difficulty: 'advanced', - type: 'value', - infoSlide: 'Cultural appropriation means members of one culture adopting elements of another culture, such as clothing, music, or symbols, often without acknowledgement.', - }, - { - id: 'cult-020', - motion: 'This House, as a minority-language community, would require its children to be schooled only in that language until age 11', - resolved: 'Resolved: Minority-language communities should require their children to be schooled only in that language until age 11.', - topic: 'culture', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'cult-021', - motion: 'This House regrets the dominance of English as the global language of culture', - resolved: 'Resolved: The dominance of English as the global language of culture has done more harm than good.', - topic: 'culture', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'cult-022', - motion: 'This House would let AI companies train on copyrighted works only with the creators\' permission', - resolved: 'Resolved: AI companies should be able to train on copyrighted works only with the creators\' permission.', - topic: 'culture', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'cult-023', - motion: 'This House believes that state funding for the arts should favor popular work over critically acclaimed work', - resolved: 'Resolved: State funding for the arts ought to favor popular work over critically acclaimed work.', - topic: 'culture', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'cult-024', - motion: 'This House, as a national broadcaster, would stop chasing ratings and make only programs commercial channels will not', - resolved: 'Resolved: National broadcasters should stop chasing ratings and make only programs that commercial channels will not.', - topic: 'culture', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'cult-025', - motion: 'This House prefers a world where heritage buildings can be freely redeveloped to one where they are strictly preserved', - resolved: 'Resolved: A world in which heritage buildings can be freely redeveloped is preferable to one in which they are strictly preserved.', - topic: 'culture', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'eth-001', - motion: 'This House would always tell a friend the truth about their bad haircut', - resolved: 'Resolved: People should always tell a friend the truth about a bad haircut.', - topic: 'ethics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'eth-002', - motion: 'This House would ban posting photos of strangers online without their permission', - resolved: 'Resolved: Posting photos of strangers online without their permission should be banned.', - topic: 'ethics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'eth-003', - motion: 'This House believes that it is sometimes right to break a promise', - resolved: 'Resolved: It is sometimes right to break a promise.', - topic: 'ethics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'eth-004', - motion: 'This House, as a student who sees a friend cheating on a test, would report it to the teacher', - resolved: 'Resolved: Students who see a friend cheating on a test should report it to the teacher.', - topic: 'ethics', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'eth-005', - motion: 'This House regrets the culture of posting every good deed online', - resolved: 'Resolved: The culture of posting every good deed online has done more harm than good.', - topic: 'ethics', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'eth-006', - motion: 'This House would require everyone to do a year of community service after leaving school', - resolved: 'Resolved: Everyone should do a year of community service after leaving school.', - topic: 'ethics', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'eth-007', - motion: 'This House believes that zoos do more good than harm for animals', - resolved: 'Resolved: Zoos do more good than harm for animals.', - topic: 'ethics', - difficulty: 'novice', - type: 'value', - }, - { - id: 'eth-008', - motion: 'This House prefers being liked to being respected', - resolved: 'Resolved: A world in which people value being liked is preferable to one in which they value being respected.', - topic: 'ethics', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'eth-009', - motion: 'This House would make it a legal duty to help a stranger in danger when it is safe to do so', - resolved: 'Resolved: It should be a legal duty to help a stranger in danger when it is safe to do so.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'eth-010', - motion: 'This House would ban the breeding of animals purely for their looks', - resolved: 'Resolved: The breeding of animals purely for their looks should be banned.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'eth-011', - motion: 'This House believes that people have a moral duty to give a tenth of their income to effective charities', - resolved: 'Resolved: People have a moral duty to give a tenth of their income to effective charities.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'eth-012', - motion: 'This House, as a whistleblower at a tech company, would leak evidence of wrongdoing to the press rather than report it internally', - resolved: 'Resolved: Whistleblowers at tech companies should leak evidence of wrongdoing to the press rather than report it internally.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'eth-013', - motion: 'This House regrets the rise of public shaming on the internet', - resolved: 'Resolved: The rise of public shaming on the internet has done more harm than good.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'eth-014', - motion: 'This House would let parents choose the genes of their children to prevent serious disease', - resolved: 'Resolved: Parents should be able to choose the genes of their children to prevent serious disease.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'eth-015', - motion: 'This House would ban products made using animal testing', - resolved: 'Resolved: Products made using animal testing should be banned.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'eth-016', - motion: 'This House believes that a good intention can excuse a bad outcome', - resolved: 'Resolved: A good intention can excuse a bad outcome.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'eth-017', - motion: 'This House prefers a society that rewards effort to one that rewards results', - resolved: 'Resolved: A world in which society rewards effort is preferable to one in which it rewards results.', - topic: 'ethics', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'eth-018', - motion: 'This House would give legal rights to rivers, forests, and other natural features', - resolved: 'Resolved: Rivers, forests, and other natural features should be given legal rights.', - topic: 'ethics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'eth-019', - motion: 'This House believes that we have stronger moral duties to people near us than to strangers far away', - resolved: 'Resolved: People have stronger moral duties to those near them than to strangers far away.', - topic: 'ethics', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'eth-020', - motion: 'This House, as a self-driving car maker, would program cars to protect pedestrians over passengers', - resolved: 'Resolved: Self-driving car makers should program cars to protect pedestrians over passengers.', - topic: 'ethics', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'eth-021', - motion: 'This House regrets the trend of judging historical figures by today\'s moral standards', - resolved: 'Resolved: The trend of judging historical figures by today\'s moral standards has done more harm than good.', - topic: 'ethics', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'eth-022', - motion: 'This House would grow meat in laboratories and phase out farming animals for food', - resolved: 'Resolved: Societies should grow meat in laboratories and phase out farming animals for food.', - topic: 'ethics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'eth-023', - motion: 'This House believes that people are morally responsible for the actions of the companies they invest in', - resolved: 'Resolved: People are morally responsible for the actions of the companies they invest in.', - topic: 'ethics', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'eth-024', - motion: 'This House, as a charity, would accept large donations from companies with harmful records', - resolved: 'Resolved: Charities should accept large donations from companies with harmful records.', - topic: 'ethics', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'eth-025', - motion: 'This House would ban AI chatbots that imitate dead loved ones', - resolved: 'Resolved: AI chatbots that imitate dead loved ones should be banned.', - topic: 'ethics', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'intl-001', - motion: 'This House would make learning a second language compulsory from the first year of school', - resolved: 'Resolved: Learning a second language should be compulsory from the first year of school.', - topic: 'international', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'intl-002', - motion: 'This House would let anyone travel to any country without a visa for holidays of up to 30 days', - resolved: 'Resolved: Anyone should be able to travel to any country without a visa for holidays of up to 30 days.', - topic: 'international', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'intl-003', - motion: 'This House believes that international student exchanges should be part of every child\'s education', - resolved: 'Resolved: International student exchanges ought to be part of every child\'s education.', - topic: 'international', - difficulty: 'novice', - type: 'value', - }, - { - id: 'intl-004', - motion: 'This House, as a popular tourist city, would cap the number of visitors allowed each day', - resolved: 'Resolved: Popular tourist cities should cap the number of visitors allowed each day.', - topic: 'international', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'intl-005', - motion: 'This House prefers vacations at home to vacations abroad', - resolved: 'Resolved: A world in which people vacation in their own country is preferable to one in which they vacation abroad.', - topic: 'international', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'intl-006', - motion: 'This House would give every country an equal number of places in the Olympic Games', - resolved: 'Resolved: Every country should get an equal number of places in the Olympic Games.', - topic: 'international', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'intl-007', - motion: 'This House believes that rich countries should give more of their wealth to poorer countries', - resolved: 'Resolved: Rich countries ought to give more of their wealth to poorer countries.', - topic: 'international', - difficulty: 'novice', - type: 'value', - }, - { - id: 'intl-008', - motion: 'This House regrets the rise of mass tourism', - resolved: 'Resolved: The rise of mass tourism has done more harm than good.', - topic: 'international', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'intl-009', - motion: 'This House would cancel the debts that poor countries owe to rich ones', - resolved: 'Resolved: Rich countries should cancel the debts that poor countries owe them.', - topic: 'international', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'intl-010', - motion: 'This House would require countries to accept a quota of refugees set by their size and wealth', - resolved: 'Resolved: Countries should be required to accept a quota of refugees set by their size and wealth.', - topic: 'international', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'intl-011', - motion: 'This House believes that foreign aid should come with conditions on how it is spent', - resolved: 'Resolved: Foreign aid ought to come with conditions on how it is spent.', - topic: 'international', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'intl-012', - motion: 'This House, as the European Union, would let any country in Europe join a looser outer tier of membership', - resolved: 'Resolved: The European Union should let any country in Europe join a looser outer tier of membership.', - topic: 'international', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'intl-013', - motion: 'This House prefers giving aid as direct cash transfers to giving it through development projects', - resolved: 'Resolved: A world in which aid is given as direct cash transfers is preferable to one in which it is given through development projects.', - topic: 'international', - difficulty: 'intermediate', - type: 'prefer', - }, - { - id: 'intl-014', - motion: 'This House would use economic sanctions only when they target named individuals rather than whole countries', - resolved: 'Resolved: Economic sanctions should target only named individuals rather than whole countries.', - topic: 'international', - difficulty: 'intermediate', - type: 'policy', - infoSlide: 'Economic sanctions are penalties, such as trade bans or frozen bank accounts, that one country places on another to pressure it.', - }, - { - id: 'intl-015', - motion: 'This House would let citizens of any country vote in a global election for the head of the United Nations', - resolved: 'Resolved: Citizens of every country should vote in a global election for the head of the United Nations.', - topic: 'international', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'intl-016', - motion: 'This House believes that international sporting bans are an effective way to pressure governments', - resolved: 'Resolved: International sporting bans are an effective way to pressure governments.', - topic: 'international', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'intl-017', - motion: 'This House regrets the decline of global trade agreements in favor of regional ones', - resolved: 'Resolved: The decline of global trade agreements in favor of regional ones has done more harm than good.', - topic: 'international', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'intl-018', - motion: 'This House would abolish the veto held by permanent members of the UN Security Council', - resolved: 'Resolved: The veto held by permanent members of the UN Security Council should be abolished.', - topic: 'international', - difficulty: 'advanced', - type: 'policy', - infoSlide: 'Five permanent members of the UN Security Council can each block any resolution on their own by using a veto.', - }, - { - id: 'intl-019', - motion: 'This House believes that the principle of non-interference in other countries\' affairs has outlived its usefulness', - resolved: 'Resolved: The principle of non-interference in other countries\' affairs has outlived its usefulness.', - topic: 'international', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'intl-020', - motion: 'This House, as the African Union, would create a single currency for its member states', - resolved: 'Resolved: The African Union should create a single currency for its member states.', - topic: 'international', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'intl-021', - motion: 'This House would create an international court with power to try companies for environmental destruction', - resolved: 'Resolved: An international court should be created with power to try companies for environmental destruction.', - topic: 'international', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'intl-022', - motion: 'This House believes that global institutions should give more voting weight to countries with larger populations', - resolved: 'Resolved: Global institutions ought to give more voting weight to countries with larger populations.', - topic: 'international', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'intl-023', - motion: 'This House would let countries sell citizenship to wealthy investors', - resolved: 'Resolved: Countries should be allowed to sell citizenship to wealthy investors.', - topic: 'international', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'intl-024', - motion: 'This House, as a low-income country, would accept large infrastructure loans from foreign powers', - resolved: 'Resolved: Low-income countries should accept large infrastructure loans from foreign powers.', - topic: 'international', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'intl-025', - motion: 'This House prefers a world with several competing great powers to one with a single dominant power', - resolved: 'Resolved: A world in which several great powers compete is preferable to one in which a single power dominates.', - topic: 'international', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'just-001', - motion: 'This House would let young offenders meet their victims instead of going to court for minor crimes', - resolved: 'Resolved: Young offenders who commit minor crimes should meet their victims instead of going to court.', - topic: 'justice', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'just-002', - motion: 'This House would require police officers to wear body cameras at all times on duty', - resolved: 'Resolved: Police officers should wear body cameras at all times on duty.', - topic: 'justice', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'just-003', - motion: 'This House believes that prisons should focus on helping prisoners change rather than on punishment', - resolved: 'Resolved: Prisons ought to focus on helping prisoners change rather than on punishment.', - topic: 'justice', - difficulty: 'novice', - type: 'value', - }, - { - id: 'just-004', - motion: 'This House, as a school, would let students sit on the panel that decides punishments for breaking school rules', - resolved: 'Resolved: Schools should let students sit on the panel that decides punishments for breaking school rules.', - topic: 'justice', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'just-005', - motion: 'This House regrets the popularity of true crime entertainment', - resolved: 'Resolved: The popularity of true crime entertainment has done more harm than good.', - topic: 'justice', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'just-006', - motion: 'This House would make fines for traffic offenses depend on how much the driver earns', - resolved: 'Resolved: Fines for traffic offenses should depend on how much the driver earns.', - topic: 'justice', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'just-007', - motion: 'This House believes that community service is a better punishment than a short prison sentence', - resolved: 'Resolved: Community service is a better punishment than a short prison sentence.', - topic: 'justice', - difficulty: 'novice', - type: 'value', - }, - { - id: 'just-008', - motion: 'This House prefers more police officers on the street to more security cameras', - resolved: 'Resolved: A world in which more police officers patrol the streets is preferable to one in which more security cameras are installed.', - topic: 'justice', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'just-009', - motion: 'This House would ban police from using prediction software to decide where to patrol', - resolved: 'Resolved: Police should be banned from using prediction software to decide where to patrol.', - topic: 'justice', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'just-010', - motion: 'This House would let people convicted of crimes vote while in prison', - resolved: 'Resolved: People convicted of crimes should be able to vote while in prison.', - topic: 'justice', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'just-011', - motion: 'This House believes that the media should not name people accused of crimes until they are convicted', - resolved: 'Resolved: The media ought not to name people accused of crimes until they are convicted.', - topic: 'justice', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'just-012', - motion: 'This House, as a city police chief, would stop arresting people for possessing small amounts of drugs', - resolved: 'Resolved: City police chiefs should stop arresting people for possessing small amounts of drugs.', - topic: 'justice', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'just-013', - motion: 'This House regrets the use of plea bargains', - resolved: 'Resolved: The use of plea bargains has done more harm than good.', - topic: 'justice', - difficulty: 'intermediate', - type: 'regret', - infoSlide: 'A plea bargain is a deal in which a defendant pleads guilty, usually to a lesser charge, in exchange for a lighter sentence.', - }, - { - id: 'just-014', - motion: 'This House would replace juries with panels of trained judges', - resolved: 'Resolved: Juries should be replaced with panels of trained judges.', - topic: 'justice', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'just-015', - motion: 'This House would wipe the criminal records of people who committed minor offenses as teenagers', - resolved: 'Resolved: The criminal records of people who committed minor offenses as teenagers should be wiped.', - topic: 'justice', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'just-016', - motion: 'This House believes that victims\' families should have a say in the sentences given to offenders', - resolved: 'Resolved: Victims\' families ought to have a say in the sentences given to offenders.', - topic: 'justice', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'just-017', - motion: 'This House would let private companies run prisons only if they are paid by how few of their prisoners reoffend', - resolved: 'Resolved: Private companies should run prisons only if they are paid by how few of their prisoners reoffend.', - topic: 'justice', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'just-018', - motion: 'This House would use AI to recommend sentences to judges', - resolved: 'Resolved: Courts should use AI to recommend sentences to judges.', - topic: 'justice', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'just-019', - motion: 'This House believes that it is better for ten guilty people to go free than for one innocent person to be convicted', - resolved: 'Resolved: It is better for ten guilty people to go free than for one innocent person to be convicted.', - topic: 'justice', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'just-020', - motion: 'This House, as a defense lawyer who privately believes their client is guilty, would still argue for an acquittal', - resolved: 'Resolved: Defense lawyers who privately believe their clients are guilty should still argue for acquittal.', - topic: 'justice', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'just-021', - motion: 'This House regrets the growing use of courts to settle political questions', - resolved: 'Resolved: The growing use of courts to settle political questions has done more harm than good.', - topic: 'justice', - difficulty: 'advanced', - type: 'regret', - }, - { - id: 'just-022', - motion: 'This House would let judges be elected by the public', - resolved: 'Resolved: Judges should be elected by the public.', - topic: 'justice', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'just-023', - motion: 'This House believes that punishment should be based on the harm a crime caused rather than on what the offender intended', - resolved: 'Resolved: Punishment ought to be based on the harm a crime caused rather than on what the offender intended.', - topic: 'justice', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'just-024', - motion: 'This House, as a country emerging from conflict, would offer amnesty to former fighters in exchange for full confessions', - resolved: 'Resolved: Countries emerging from conflict should offer amnesty to former fighters in exchange for full confessions.', - topic: 'justice', - difficulty: 'advanced', - type: 'actor', - infoSlide: 'An amnesty is an official pardon that means people will not be prosecuted for certain past crimes.', - }, - { - id: 'just-025', - motion: 'This House prefers fixed sentences for each crime to letting judges choose sentences freely', - resolved: 'Resolved: A world in which each crime carries a fixed sentence is preferable to one in which judges choose sentences freely.', - topic: 'justice', - difficulty: 'advanced', - type: 'prefer', - }, - { - id: 'soc-001', - motion: 'This House would ban children under 14 from having social media accounts', - resolved: 'Resolved: Children under 14 should be banned from having social media accounts.', - topic: 'society', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'soc-002', - motion: 'This House would give every teenager a free bus pass', - resolved: 'Resolved: Every teenager should get a free bus pass.', - topic: 'society', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'soc-003', - motion: 'This House believes that it is better to grow up in a big city than in the countryside', - resolved: 'Resolved: It is better to grow up in a big city than in the countryside.', - topic: 'society', - difficulty: 'novice', - type: 'value', - }, - { - id: 'soc-004', - motion: 'This House, as a parent, would let their child decide when they get their first phone', - resolved: 'Resolved: Parents should let their children decide when they get their first phone.', - topic: 'society', - difficulty: 'novice', - type: 'actor', - }, - { - id: 'soc-005', - motion: 'This House prefers having a few close friends to having many casual friends', - resolved: 'Resolved: A world in which people have a few close friends is preferable to one in which they have many casual friends.', - topic: 'society', - difficulty: 'novice', - type: 'prefer', - }, - { - id: 'soc-006', - motion: 'This House would make every neighborhood have a free youth center', - resolved: 'Resolved: Every neighborhood should have a free youth center.', - topic: 'society', - difficulty: 'novice', - type: 'policy', - }, - { - id: 'soc-007', - motion: 'This House believes that teenagers today have it harder than their parents did', - resolved: 'Resolved: Teenagers today have it harder than their parents did.', - topic: 'society', - difficulty: 'novice', - type: 'value', - }, - { - id: 'soc-008', - motion: 'This House regrets the rise of group chats as the main way friends talk', - resolved: 'Resolved: The rise of group chats as the main way friends talk has done more harm than good.', - topic: 'society', - difficulty: 'novice', - type: 'regret', - }, - { - id: 'soc-009', - motion: 'This House would require social media platforms to switch off infinite scrolling by default', - resolved: 'Resolved: Social media platforms should switch off infinite scrolling by default.', - topic: 'society', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'soc-010', - motion: 'This House would require large housing developments to include homes for older and younger people side by side', - resolved: 'Resolved: Large housing developments should include homes for older and younger people side by side.', - topic: 'society', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'soc-011', - motion: 'This House believes that loneliness should be treated as a public health crisis', - resolved: 'Resolved: Loneliness ought to be treated as a public health crisis.', - topic: 'society', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'soc-012', - motion: 'This House, as a social media company, would hide like counts on all posts', - resolved: 'Resolved: Social media companies should hide like counts on all posts.', - topic: 'society', - difficulty: 'intermediate', - type: 'actor', - }, - { - id: 'soc-013', - motion: 'This House prefers living with extended family to living as a nuclear family', - resolved: 'Resolved: A world in which people live with their extended families is preferable to one in which they live as nuclear families.', - topic: 'society', - difficulty: 'intermediate', - type: 'prefer', - infoSlide: 'A nuclear family is parents and their children living together, while an extended family also includes grandparents, aunts, uncles, or cousins.', - }, - { - id: 'soc-014', - motion: 'This House would give equal paid leave to both parents after the birth of a child', - resolved: 'Resolved: Both parents should get equal paid leave after the birth of a child.', - topic: 'society', - difficulty: 'intermediate', - type: 'policy', - }, - { - id: 'soc-015', - motion: 'This House would turn empty office buildings into social housing', - resolved: 'Resolved: Empty office buildings should be turned into social housing.', - topic: 'society', - difficulty: 'intermediate', - type: 'policy', - infoSlide: 'Social housing is housing owned by the government or a non-profit and rented at a lower price to people who need it.', - }, - { - id: 'soc-016', - motion: 'This House believes that working from home has been good for society', - resolved: 'Resolved: Working from home has been good for society.', - topic: 'society', - difficulty: 'intermediate', - type: 'value', - }, - { - id: 'soc-017', - motion: 'This House regrets the rise of dating apps', - resolved: 'Resolved: The rise of dating apps has done more harm than good.', - topic: 'society', - difficulty: 'intermediate', - type: 'regret', - }, - { - id: 'soc-018', - motion: 'This House would require all new neighborhoods to mix homes of different prices on the same streets', - resolved: 'Resolved: All new neighborhoods should mix homes of different prices on the same streets.', - topic: 'society', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'soc-019', - motion: 'This House believes that meritocracy is a myth that harms those who do not succeed', - resolved: 'Resolved: Meritocracy is a myth that harms those who do not succeed.', - topic: 'society', - difficulty: 'advanced', - type: 'value', - infoSlide: 'Meritocracy is the idea that people rise in society based on their talent and effort rather than their background.', - }, - { - id: 'soc-020', - motion: 'This House, as a feminist movement, would campaign for men\'s issues such as boys falling behind in school', - resolved: 'Resolved: Feminist movements should campaign for men\'s issues such as boys falling behind in school.', - topic: 'society', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'soc-021', - motion: 'This House would let communities vote to keep chain stores out of their town centers', - resolved: 'Resolved: Communities should be able to vote to keep chain stores out of their town centers.', - topic: 'society', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'soc-022', - motion: 'This House believes that identity-based activism does more to divide society than unite it', - resolved: 'Resolved: Identity-based activism does more to divide society than to unite it.', - topic: 'society', - difficulty: 'advanced', - type: 'value', - }, - { - id: 'soc-023', - motion: 'This House, as a city with an aging population, would pay young families to move there', - resolved: 'Resolved: Cities with aging populations should pay young families to move there.', - topic: 'society', - difficulty: 'advanced', - type: 'actor', - }, - { - id: 'soc-024', - motion: 'This House would give every adult a fixed number of hours of paid leave to care for older relatives', - resolved: 'Resolved: Every adult should have a fixed number of hours of paid leave to care for older relatives.', - topic: 'society', - difficulty: 'advanced', - type: 'policy', - }, - { - id: 'soc-025', - motion: 'This House prefers a society with strong shared traditions to one that encourages individual self-expression', - resolved: 'Resolved: A world in which society has strong shared traditions is preferable to one that encourages individual self-expression.', - topic: 'society', - difficulty: 'advanced', - type: 'prefer', - }, -]; +export * from '@debatelab/core/free-tools/practice-motions'; diff --git a/src/lib/free-tools/round-timer.ts b/src/lib/free-tools/round-timer.ts index 657f6624b..61859fc60 100644 --- a/src/lib/free-tools/round-timer.ts +++ b/src/lib/free-tools/round-timer.ts @@ -1,167 +1 @@ -/** - * The free round timer's state, as pure functions of wall-clock time so a - * running round survives a reload. A speech counts up from zero and keeps - * counting past its limit (overtime); prep banks are the in-app countdown - * clocks from timer-clock.ts and stop at zero. - */ -import { pauseClock, remainingMs, startClock, type ClockState } from '@/lib/timer-clock'; -import type { RoundFormat, RoundSegment } from './formats'; - -export interface Stopwatch { - /** Milliseconds counted before the current run. */ - elapsedMs: number; - /** When the current run began, while running. */ - startedAt: number | null; -} - -export type PrepSide = 'a' | 'b'; - -export interface RoundState { - segmentIndex: number; - speech: Stopwatch; - prep: Record; -} - -export function elapsedMs(watch: Stopwatch, now: number): number { - return watch.startedAt === null ? watch.elapsedMs : watch.elapsedMs + Math.max(0, now - watch.startedAt); -} - -const stoppedAt = (watch: Stopwatch, now: number): Stopwatch => ({ elapsedMs: elapsedMs(watch, now), startedAt: null }); -const PAUSED: Stopwatch = { elapsedMs: 0, startedAt: null }; - -export function initialRoundState(format: RoundFormat): RoundState { - const bank = (): ClockState => ({ remainingMs: format.prepSeconds * 1000, deadline: null }); - return { segmentIndex: 0, speech: PAUSED, prep: { a: bank(), b: bank() } }; -} - -export const isSpeechRunning = (state: RoundState) => state.speech.startedAt !== null; -export const isPrepRunning = (state: RoundState, side: PrepSide) => state.prep[side].deadline !== null; - -function pausePrep(prep: RoundState['prep'], now: number): RoundState['prep'] { - return { a: pauseClock(prep.a, now), b: pauseClock(prep.b, now) }; -} - -/** Starts or pauses the speech. A speech and prep never run together: starting one stops the other. */ -export function toggleSpeech(state: RoundState, now: number): RoundState { - if (isSpeechRunning(state)) return { ...state, speech: stoppedAt(state.speech, now) }; - return { ...state, speech: { elapsedMs: state.speech.elapsedMs, startedAt: now }, prep: pausePrep(state.prep, now) }; -} - -/** Starts or pauses one side's prep bank; the other side's bank and the speech pause. An empty bank stays put. */ -export function togglePrep(state: RoundState, side: PrepSide, now: number): RoundState { - if (isPrepRunning(state, side)) return { ...state, prep: { ...state.prep, [side]: pauseClock(state.prep[side], now) } }; - const prep = pausePrep(state.prep, now); - return { ...state, speech: stoppedAt(state.speech, now), prep: { ...prep, [side]: startClock(prep[side], now) } }; -} - -export function prepRemainingMs(state: RoundState, side: PrepSide, now: number): number { - return remainingMs(state.prep[side], now); -} - -/** Pauses a prep bank that has run out, so a reload does not show it running at 0:00. */ -export function settlePrep(state: RoundState, now: number): RoundState { - const settled = (side: PrepSide) => (isPrepRunning(state, side) && prepRemainingMs(state, side, now) === 0 - ? { remainingMs: 0, deadline: null } - : state.prep[side]); - const a = settled('a'); - const b = settled('b'); - return a === state.prep.a && b === state.prep.b ? state : { ...state, prep: { a, b } }; -} - -/** - * Moves to another segment with a fresh, paused speech clock. Prep banks carry - * over. Asking for the segment already shown, including past either end of the - * round, changes nothing, so a stray arrow key never wipes a running speech. - */ -export function goToSegment(state: RoundState, format: RoundFormat, index: number, now: number): RoundState { - const segmentIndex = Math.min(Math.max(0, index), format.segments.length - 1); - if (segmentIndex === state.segmentIndex) return state; - return { segmentIndex, speech: PAUSED, prep: pausePrep(state.prep, now) }; -} - -export function resetSpeech(state: RoundState): RoundState { - return { ...state, speech: PAUSED }; -} - -/** Restores both prep banks to the format's full allowance. */ -export function resetPrep(state: RoundState, format: RoundFormat): RoundState { - return { ...state, prep: initialRoundState(format).prep }; -} - -export type PoiState = 'protected' | 'open'; - -export interface SegmentStatus { - /** Milliseconds left; negative once the speaker is over time. */ - remainingMs: number; - overtime: boolean; - /** Over time but inside the format's grace period. */ - inGrace: boolean; - /** Only for speeches that take points of information. */ - poi: PoiState | null; -} - -export function segmentStatus(segment: RoundSegment, elapsed: number, graceSeconds = 0): SegmentStatus { - const limit = segment.seconds * 1000; - const remaining = limit - elapsed; - const overtime = remaining < 0; - const poi = segment.poi - ? (elapsed >= segment.poi.opensAt * 1000 && elapsed < segment.poi.closesAt * 1000 ? 'open' : 'protected') - : null; - return { remainingMs: remaining, overtime, inGrace: overtime && -remaining <= graceSeconds * 1000, poi }; -} - -export type TimerCue = 'knock' | 'beep' | 'double-beep'; - -/** - * The cue to sound when the clock moves from `before` to `after` elapsed - * milliseconds: a knock when points of information open or close, a beep with - * one minute left, a double beep at time. Marks that fall together sound once, - * the most important first. - */ -export function cueBetween(segment: RoundSegment, before: number, after: number): TimerCue | null { - if (after <= before) return null; - const crossed = (seconds: number) => before < seconds * 1000 && after >= seconds * 1000; - if (crossed(segment.seconds)) return 'double-beep'; - if (segment.poi && (crossed(segment.poi.opensAt) || crossed(segment.poi.closesAt))) return 'knock'; - if (segment.seconds > 60 && crossed(segment.seconds - 60)) return 'beep'; - return null; -} - -/** "-0:12" style: overtime reads as time past the limit, counting up. */ -export function clockFace(remaining: number): { text: string; overtime: boolean } { - const overtime = remaining < 0; - // Counting down, 0.2 s left still reads 0:01; counting up, 0.2 s over reads +0:00. - const seconds = overtime ? Math.floor(-remaining / 1000) : Math.ceil(remaining / 1000); - const text = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; - return { text: overtime ? `+${text}` : text, overtime }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0; -const isTimeOrNull = (value: unknown): value is number | null => value === null || isTime(value); - -/** A stored state read back from localStorage, or null when it does not fit the format. */ -export function parseRoundState(value: unknown, format: RoundFormat): RoundState | null { - if (!isRecord(value) || !isRecord(value.speech) || !isRecord(value.prep)) return null; - const { segmentIndex, speech, prep } = value; - if (!Number.isInteger(segmentIndex) || Number(segmentIndex) < 0 || Number(segmentIndex) >= format.segments.length) return null; - const { elapsedMs: counted, startedAt } = speech; - if (!isTime(counted) || !isTimeOrNull(startedAt)) return null; - const bank = (side: unknown): ClockState | null => { - if (!isRecord(side)) return null; - const { remainingMs: left, deadline } = side; - if (!isTime(left) || !isTimeOrNull(deadline)) return null; - return { remainingMs: Math.min(left, format.prepSeconds * 1000), deadline }; - }; - const a = bank(prep.a); - const b = bank(prep.b); - if (!a || !b) return null; - return { - segmentIndex: Number(segmentIndex), - speech: { elapsedMs: counted, startedAt }, - prep: { a, b }, - }; -} +export * from '@debatelab/core/free-tools/round-timer'; diff --git a/src/lib/free-tools/speech-time.ts b/src/lib/free-tools/speech-time.ts index fc6a14d5b..cc0315c33 100644 --- a/src/lib/free-tools/speech-time.ts +++ b/src/lib/free-tools/speech-time.ts @@ -1,60 +1 @@ -/** Word counts and spoken-duration estimates for the speech time calculator. */ -import { segmentRole, speakingSegments, type RoundFormat } from './formats'; - -/** Rough speaking rates. These are working approximations for practice, not measured norms. */ -export const PACES = [ - { id: 'slow', wpm: 110 }, - { id: 'average', wpm: 130 }, - { id: 'fast', wpm: 160 }, -] as const; -export type PaceId = (typeof PACES)[number]['id']; - -export const MIN_WPM = 60; -export const MAX_WPM = 400; - -/** Words as a reader counts them: "don't", "well-known" and "2026" are one word each; dashes and bullets are none. */ -export function countWords(text: string): number { - return text.match(/[\p{L}\p{N}]+(?:['’\-.][\p{L}\p{N}]+)*/gu)?.length ?? 0; -} - -export function spokenSeconds(words: number, wpm: number): number { - if (!(wpm > 0)) return 0; - return (words / wpm) * 60; -} - -export interface SlotFit { - formatId: string; - formatShortName: string; - /** The speeches of this length, by role: "constructive / rebuttal". */ - roles: string; - slotSeconds: number; - /** Positive when the speech fits with time to spare; negative when it runs over. */ - spareSeconds: number; - fits: boolean; -} - -/** - * How a speech of `words` words at `wpm` fits each distinct speech length in - * the formats, one row per format and length. - */ -export function slotFits(words: number, wpm: number, formats: readonly RoundFormat[]): SlotFit[] { - const spoken = spokenSeconds(words, wpm); - return formats.flatMap((format) => { - const byLength = new Map(); - for (const segment of speakingSegments(format)) { - const roles = byLength.get(segment.seconds) ?? []; - const role = segmentRole(segment); - if (!roles.includes(role)) roles.push(role); - byLength.set(segment.seconds, roles); - } - return [...byLength].map(([slotSeconds, roles]) => { - const spareSeconds = Math.round(slotSeconds - spoken); - return { formatId: format.id, formatShortName: format.shortName, roles: roles.join(' / '), slotSeconds, spareSeconds, fits: spareSeconds >= 0 }; - }); - }); -} - -/** Words that fill a slot at a pace: the target length for a speech you are writing. */ -export function wordsForSlot(slotSeconds: number, wpm: number): number { - return Math.round((slotSeconds / 60) * wpm); -} +export * from '@debatelab/core/free-tools/speech-time'; diff --git a/src/lib/timer-clock.ts b/src/lib/timer-clock.ts index c563bdb01..9d2ab9af6 100644 --- a/src/lib/timer-clock.ts +++ b/src/lib/timer-clock.ts @@ -1,29 +1 @@ -/** - * Speech clock arithmetic in milliseconds. Pausing keeps the exact remainder, so a - * pause never hands the speaker the rest of the current second. - */ -export interface ClockState { - /** Milliseconds left while paused; ignored while running. */ - remainingMs: number; - /** When the speech ends, while running. */ - deadline: number | null; -} - -export function startClock(state: ClockState, now: number): ClockState { - if (state.deadline !== null || state.remainingMs <= 0) return state; - return { remainingMs: state.remainingMs, deadline: now + state.remainingMs }; -} - -export function pauseClock(state: ClockState, now: number): ClockState { - if (state.deadline === null) return state; - return { remainingMs: Math.max(0, state.deadline - now), deadline: null }; -} - -export function remainingMs(state: ClockState, now: number): number { - return state.deadline === null ? state.remainingMs : Math.max(0, state.deadline - now); -} - -/** Whole seconds shown on the clock face: 0.2s left still reads 0:01. */ -export function displaySeconds(ms: number): number { - return Math.ceil(ms / 1000); -} +export * from '@debatelab/core/timer-clock'; From d7bff5f61c72a69cd0cfec4a8a35c70f8f004ab8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:01:14 +0000 Subject: [PATCH 02/11] Give the app the website's free round timer, with a Live Activity The Timer now runs the free timer's formats (BP, WSDC, AP, PF, LD, Policy and the school format), each speech's points-of-information window with the knock, prep banks for both sides, overtime and grace, and custom formats saved on the device. The round is kept in AsyncStorage and its signals run from the root layout, so closing the timer leaves a speech running. With an App Group (DEBATELAB_APP_GROUP) the development build also shows the clock as a Live Activity in the Dynamic Island and on the Lock Screen, and as a lock-screen widget, drawn by iOS from SwiftUI timer texts. Expo Go and builds without the group skip all of it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/app.config.ts | 22 +- apps/mobile/assets/sounds/README.md | 9 +- apps/mobile/assets/sounds/knock.wav | Bin 0 -> 13274 bytes apps/mobile/package.json | 2 + apps/mobile/scripts/render-timer-sounds.ts | 22 +- apps/mobile/scripts/screenshots.ts | 67 ++- apps/mobile/src/app/_layout.tsx | 4 + apps/mobile/src/app/timer-format.tsx | 222 ++++++++ apps/mobile/src/app/timer.tsx | 513 ++++++++++++------ apps/mobile/src/components/timer-runner.tsx | 110 ++++ apps/mobile/src/lib/i18n.ts | 10 +- apps/mobile/src/lib/live-activity.ts | 88 +++ apps/mobile/src/lib/live-activity.web.ts | 10 + apps/mobile/src/lib/timer-signals.ts | 15 +- apps/mobile/src/lib/timer-store.ts | 90 +++ apps/mobile/src/lib/timer.test.ts | 104 ++-- apps/mobile/src/lib/timer.ts | 322 +++++++---- apps/mobile/src/widgets/timer-widgets.tsx | 144 +++++ package-lock.json | 46 +- .../core/src/free-tools/custom-format.test.ts | 25 + src/locales/en.json | 11 +- src/locales/es.json | 11 +- src/locales/hu.json | 11 +- 23 files changed, 1464 insertions(+), 394 deletions(-) create mode 100644 apps/mobile/assets/sounds/knock.wav create mode 100644 apps/mobile/src/app/timer-format.tsx create mode 100644 apps/mobile/src/components/timer-runner.tsx create mode 100644 apps/mobile/src/lib/live-activity.ts create mode 100644 apps/mobile/src/lib/live-activity.web.ts create mode 100644 apps/mobile/src/lib/timer-store.ts create mode 100644 apps/mobile/src/widgets/timer-widgets.tsx create mode 100644 packages/core/src/free-tools/custom-format.test.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 14320679f..0d2a8ba4a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -7,9 +7,13 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; // One config for Expo Go, the web export and the development build. // DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the // native modules Expo Go lacks. Without it the config is the one Expo Go has -// always used. DEBATELAB_APP_GROUP adds the lock-screen timer's widget -// extension, which needs an App Group: set it only when the provisioning -// profile carries that group. See docs/development.md#development-build. +// always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live +// Activity, Dynamic Island clock and lock-screen widget), which needs an App +// Group: set it only when the provisioning profile carries that group. +// PLACEHOLDER until Adam's Apple Developer account exists: the group to create +// there is `group.com.adam.debatelab` (it follows the bundle id), registered +// for both `com.adam.debatelab` and `com.adam.debatelab.ExpoWidgetsTarget`. +// See docs/development.md#development-build. type PermissionStrings = { speechRecognition: string; microphone: string }; @@ -125,7 +129,17 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { bundleIdentifier: `${bundleIdentifier}.ExpoWidgetsTarget`, groupIdentifier: appGroup, enablePushNotifications: false, - widgets: [], + widgets: [ + // The timer's lock-screen widget (src/widgets/timer-widgets.tsx). The Live + // Activity and Dynamic Island need no entry: the extension draws every + // Live Activity the app starts. + { + name: 'DebateTimerWidget', + displayName: 'DebateOwl timer', + description: 'The speech or prep clock running in the DebateOwl timer.', + ios: { supportedFamilies: ['accessoryRectangular', 'accessoryCircular', 'accessoryInline'] }, + }, + ], }, ]); } diff --git a/apps/mobile/assets/sounds/README.md b/apps/mobile/assets/sounds/README.md index b7b815e82..fff6dacd6 100644 --- a/apps/mobile/assets/sounds/README.md +++ b/apps/mobile/assets/sounds/README.md @@ -1,7 +1,8 @@ # Timer sounds -`beep.wav` (one minute left) and `double-beep.wav` (time up) are generated by +`beep.wav` (one minute left), `double-beep.wav` (time up) and `knock.wav` +(points of information open or close) are generated by [scripts/render-timer-sounds.ts](../../scripts/render-timer-sounds.ts), which -copies the website's Web Audio synth in `src/pages/TimerPage.tsx`: an 880 Hz -sine at 22.05 kHz, 16-bit mono. Rerun the script to change them; don't edit -the files by hand. +copies the website's Web Audio synth in `src/lib/timer-signals.ts`: an 880 Hz +sine for the beeps and two 180 Hz triangle taps for the knock, at 22.05 kHz, +16-bit mono. Rerun the script to change them; don't edit the files by hand. diff --git a/apps/mobile/assets/sounds/knock.wav b/apps/mobile/assets/sounds/knock.wav new file mode 100644 index 0000000000000000000000000000000000000000..0c292a734a02b31cca7f18bc390532f2ebd2cd61 GIT binary patch literal 13274 zcmeI2cUV-{*2ecf=S+v8Nf%TUY}g~Q$A$)su`8NbW7NbNyU{dT>@CI;V~fV#Vv8k4 zY}X!%id|5ebQqX==A6C1cgEcN-*>;izvsd8z~gwzUhlis+B42?^-b#5?S2bF26Y;k zIA-GXn*M|kiX&zqAzcpC99QuML3zvmN@qsu& zGHd?OOw+!jBl>jxNyAp-YSS{G)xKNJC;ig>SwNGZqM)>(13`0wIs~}__XqY0EDrcO zKo_vc-|aWn@1{A%Jk|HCkJHr1G}!o+VUzx#?zHxd=D4(5TqR7QJxCPKR}U%U+)?sv z=NArt`+jSi%4?RS^6RDTOO6zU70%0ln%6XUe)iqhL9hB|tay?7tn5kH<8A5hJ1&D`r1tJ;?zCR$;r_?skZ`|7sq3k}Upvwi;XRn6`E=LDPxEDEX>JRoFo=;^TR zaNo#;>K{ja7rm>-m71?>sWBmS8pqbIb2nyAt-3WIM=y(duev?rX!!WB`k@8E2dhmE zN(kisH~lu4KlM%UF`63p`~GEq2<*qeiepFYIRtPZ5j47$3^F1*JgR4d!o``ZOx)NA+Jd) z-6nh~4v-paG}`CdW4ZpU{jt71b)VNwWmbvBGWj)GHmDVU-Ug9iXUEHO_Ub43|rA$|Ts(h5iTyeHyRHey!+?rxjZ9DB< z9J!7~&REwK*9cjWH@cfCx0F$;OI^ik^WS+Ac}~VtH(e@(io3)n(pjmC=C-Dv_NjKb zE?YNNU#Op8C^k$m78yS?y)k{_li`!%d*3(NeA(RE@2Fn~za!=v<~6>8?@XUU(+JZe zV^8BXLkGhdeGC0jT|?b|ZC&kNO_H(vDHk)<5wT1OT<@Cy*D$UmU zR-1LK?WrxCXadRDE@zGj)MBj15c z4<{E$B%MVc(k8+(AxG#SZWPPJ?$S2NCM9cjXsnv|wLfc1wVib9;6e-i0{wk`EyHBP z1%tsDVmxCQVo>!v^@;jI-5Onj?xl92HeQ>q`C3y?^FaDqsxPIB^Tj6OOJN1{7Sf++ z52}zO*h7NIbv~Cj;W_Ll)|+YB1$BnnNX=8WD}$9FN$bSu7vTck$P}2Kkt5#vT?(Kcq|PRmx~9VXANt>Z^Mp8&DvGU7&Egu2hoZVwP*cYrb3u3wI@9HvjsM=H&)yK*~ zuB<+^yi#C^v7#vP(0D$|u;lr)7Y4b>ENk@}nZ zQZ=xaYy?}*PP13c$lu{Z`C@*QKS8GJk=|q$*+#CBVkmD#htY*}KmChV(op1PxUfLj zBis^-1wXMlT$qVEI4eFA-D0HFPU<9uOBLd6aku!DI0)5i5_5%AVT&+R=p!@|3_>ow zM7PnoP}`CQLGL|sn5-b5lO$4~=t(}m!T0ec{4<`+8*(3B0`0%CAJ`0*!rHQEMp=$} zLp`LfQKv&kdo@PYsm02D<#%PPvP?OEE-h3HYCW~PItD6tsh8ADRaGNdD>i^lWvkgi zc7x?G!mIOlc|SghFXg-WpZqDW0~9@LoSlXq@3t!O{nip$I@@;26`Cz ze@-n_FGLIP2+6_-Xj(4p5Kh61)52D`@;NlN5^4xqp_Hc63-niHbq3t)PMgp$N@)>! zNd6>y$yzd(jDg2(kY|75=6U>Y{s(lg=il)0JcW1VO?ebIa68LpY3u@`-ozHO>1-70 zzXOYB5zN4xYMz>|UR7_auT&dy8O55jZfppf$i8Fi**lfV5#gbd&b^CCFN!w!RzuCyd(br4vywi_&n6ndhA>Wv14E6e<8~`&@6Ke zJdY%Cq#2^=hAezUJ|&aMmr%coY$7|!L2?TDzX1=PlU!0t9Pq)2TCGlF;YBQsfGZkk zEW_F7B#r!w{6UVBedw3<=-qG0Ofr#tg1x#o=}y{{cSu8c9F8@0gmD`$MikHaeSV8y z;%E79$odZCWDR2dmd{4iEE7}-kZoq1d6iRZDXg%Iw?1EH`sUcbZJ^F;n3AIOLC zv3xS01?Rr!tNBL0o$uvG_-R!1H8}f_zu?)tkXLXQGOZ(K5=^R-+N1%Lw;~-#BKd&y zMV!Ntn+aqpnM1xIi^y{1YZJ8WCI^tcQ{+6kL@wj^N%ZL+vK{Bv;+lozE9?+c(6^tE zp=2PmCZmellXvm#hN$WqP#i>ju_`4BxASsd#B=#8{*0&dd(eKBU*zZbDeN}~_^)ta z3*UghY^Piw|H|{&iPr|`J`4#LLcj4$` z=zWb|D#B`3Xl7i%`aWaMw4;yVG3$> zHv01$G7k!;l1bz<)W=A?52T<=dXXN;YG=6D2A^-6<5NfjJT-Tl_krPURO6|7q0!F@6|&_TU|72j2>PTcB_kULQa< ze}hYBusdAD9sk0ecX=9~@)%BLKy40xgAA8)3)XU=wpC4SRcg})`CFS_7dd#G$hT0&A3zW?#saaS{T=Y+RLxJ<+4E*sHPJtI~>R+M(2m6=bMYpqD{0N2Wdh{}K>|h*%`( z_Z(X9p@SEmL;t@Vo^zfnJ$HES^*lrH9-e1=1{R5?)PK$Qdk-GQ$74skCC$`g`Fp5oi&L-@|LH@^A4fp0T|_&(NxodBm; zuh`rlxHro$Tp{4L6C6wIKil?L_f~GNSZ$e6-mk1)skQiQ(Tu`+1rOiM&g+qTAU7~? zK_1In@FpOCUw%?SMZxaE5k+;1%Zsm->?&PaHmiK1WkSW&%I~ZjZKv$d96DE9d6xT} zBC!GB(>}sfF+=L3y{+qExNjWpQ{lVZuVFx1;M{8QA%{Yv!k0zZB8Ns@iH@uJeXZP> zj&(N2mc@0cx4wRMgC_A64L+)Wy>3F>-a3)B*VZ!CTpX>5S{!MNSQ8c=x;waewJU)G z1B(0>n4^8qn}!$_{Z3tXtws7-OcrEvn2%!N>I3&`xu?t5nP&gd_OUgtvci&DzNTzc z>ANMq#d(F73by5c^=4RJ=iItEe%a2~Ij`<#Ud}l2a_@_PmqRjkWahkz%NmotJ0~+Y z>`m|dMFnRH3yMNZx|U8Z+gSdmCA&gnjk6`%M>^&?*SikNsqP0#j#|kGG0|YKx)>== zQ&(G07pIRgL>WU&zCMDl-8bL-#P47JCjzzwejhZcTL0iyA(5d<=<~2M;p-zNMRu?L zS@jW-gCqKd_X_J7+B4+C;6Bv`27Mg(X}~1^Fa5qZZ}8pgbJldnm}PM31NHH`_qCsD z7Ge(JI_RYzO(4Vg66E-mVpcl1C(FOM9yxuST^w`mr)(B$BkP39gB8V=#+Iq&zn8Jn zZl$YBo)*UzPc6Ds7*IH>AUFSf!Ro?Kiry*KlsqiiSvs+-Rk>ifWm#J>urk8>(z?wy z%pT=<=Gfru<1)%uzfz)zVhkd z^Rr1Y4mJL1sAX6Le%o93yS9dQsismICfyQSiF<@#VKH@p2D}8hxX#+MlOVKvl$y#G zcc^>494N1K1-RBZgPa>2;f`(g+V=gnrnWQIPS)F%11euvOstSCODv(5u9m+nDHX3P z##NS8&aleXZ)}2nps?=WyYoA=)_4_|%wST4yRX zDW(;sDAQ46C*woIIB@E9`g;0{x`Db^fBXk$?!M)YC!J<$B;;>}=&McI|5+M`-hG_4(1r(JmeHyDXzmVDi4tN$!@u)d%L^b-BDSiWGao-dFm}S zl#OP`n8Lb)mptdO;1j0_BZ+hceL%y7VZtsUUuY;!7LSWoF+rM**#uc?qnQH=?a(A> zr)hr!p>LoYqx(hoQny?8v96l#u6D8ZJ*`V~QZrf8K$9o^A`O?SOV7kj;I|=Sy6~eg zPzVv8fb@L?8u%JyXdGyO1>|BL7?qyiV;k5o7RyS(roUB_)FAbxvQL?&yr-Cy$L?L2 zPw3z_xt~GLEV-K;BImhIyKcHfxjkMVlMCb+_Yn75_f0oZ5|oL`F3@;iwX-@)J*Z}= zfh>{DVh7j@<_m^78GIxSnQjVZwF=a&kc820bQ*}{UFxE-ps(|Uy~2G#5#q$&;+L4s zxC0leOI@W2(t7EmYqU zfCdf+8HxwRlR+nLfKhD(gB#0x@m9Ph*mMPZ&aSej;?8 zV^9zRhEor$t`{idOeo(0Qh27QqVcE6b}rqk(2%yF~>r49wbt%TMzFzQ2O3%KnZRP`Wmua=-c)j&k7U^WlI zDNlk={s?Y96WUXF5^saKgecHv!Y!cl>Fhc?3mx0o8a5wO3gbZFd$O*qKe}`}=5&5w zyV!B4{2MbCWsISUBY6Wj*qsjmkDm(WyArf-KM3_z5X?+yc5xlnuZa$81Jc+JTyg?< z13j!%pL9g;bs2+kdrlP+O@n68ESAo3FehrJ0BE7;x$Yu=r$5Sqz19Q}~yB0r>6) zkj;Hyn&*(=JK%d6U|waQTLh~IfYsFkn`;i`ok1k~Vj5!<=<65A&9`I;Sxq*AOYcE- zo`jamLrW6JWA?u;2Uy;=BrUem-`)nb0wwf69l0(Dw&{ zPeRT1K$i~2oX!|bW=(_2Z&52N`45v*fjweF3 z8P10xn%c-hBQKk75B1$aVf%oqf8^!TpFzVk(CB%{+#=NKD)i?@{H%m43!rf}&Q3!8 zenN(UW%ozFB%^mbg7A76bt62vHmGd`h;0Dqs2;43gC*J#g@;b(BBmE!Hk}6bHxO$o zc=H)l{xQVA5B0wjRC+UVw+>D$2cusKg=_KpC%z38vKJE;N8#7+m@Dy6>gyo8_u%AX zsLce|%=?E>+fdsIGA)5UnxNZ5r^7&4YaqHfaM$>M$aHHjm+pdIO!89czGMKh_Yt&> z#4!w2F$jIp+smmF;njQ4`Ib^Q2EVO`+`eVh;a)=R2gL@gD1m7*FO~N&S`U{mM6G++ zbS8-GGw4r82c~(s^bOR&TPpnrD)n~|_!Fqvlla>?FNuH4sXZ+I9`5l_Y7fWF^fKx< zaJ1M<v?HxC zThS8r@s?9Jz}0bHN?ilJ8;Q>G@c1A++3z1htpnNhF!{H9TK*56w*14UJ?cGdJ{wVc z==@7$>nU7#1U(PDPIwsoUr_k>e@OgWTKx+3@^4yQ_z#aSM=g0swTDxCD7A-CduD;qar+qhrWB1h2gqjTpx%#{h`mp z;C)a*MlXl=aO}4f`z^u#H@o%_a^^i$M57`L|JU*MI#1-xb2I+@_y6Hlovm`9%7H2e NsvM|t;QzA&{{v)%pWFZd literal 0 HcmV?d00001 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 49c78b0e8..f9ea3481d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -13,6 +13,7 @@ "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", "@expo/dom-webview": "~57.0.1", + "@expo/ui": "~57.0.23", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", @@ -38,6 +39,7 @@ "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.22", + "expo-speech": "~57.0.3", "expo-speech-recognition": "^57.1.1", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", diff --git a/apps/mobile/scripts/render-timer-sounds.ts b/apps/mobile/scripts/render-timer-sounds.ts index 691727656..de1ad749a 100644 --- a/apps/mobile/scripts/render-timer-sounds.ts +++ b/apps/mobile/scripts/render-timer-sounds.ts @@ -1,9 +1,11 @@ /** * Writes the Timer's signal sounds to assets/sounds as 16-bit mono WAV files. - * They copy the website's Web Audio synth (playSignal in src/pages/TimerPage.tsx): + * They copy the website's Web Audio synth (playSignal in src/lib/timer-signals.ts): * an 880 Hz sine, one beep fading out over 0.5 s, and two beeps for time up * (0.1 s on, a 50 ms gap, then a fade to 0.6 s). The website plays them at a * tenth of full scale; a phone speaker needs more, so these peak at half. + * The knock (points of information open or close) is two short 180 Hz + * triangle taps, 0.18 s apart, each fading out over 80 ms. * Run from the repository root after changing a sound: * * npx tsx apps/mobile/scripts/render-timer-sounds.ts @@ -27,12 +29,19 @@ function envelope(t: number, from: number, to: number, holdUntil = from): number return attack * release * fade; } -function render(seconds: number, gain: (t: number) => number): Buffer { +const sine = (t: number, frequency: number) => Math.sin(2 * Math.PI * frequency * t); +// The Web Audio triangle: a linear ramp between -1 and 1. +const triangle = (t: number, frequency: number) => { + const phase = (t * frequency) % 1; + return phase < 0.25 ? 4 * phase : phase < 0.75 ? 2 - 4 * phase : 4 * phase - 4; +}; + +function render(seconds: number, gain: (t: number) => number, wave = sine, frequency = FREQUENCY): Buffer { const samples = Math.round(seconds * RATE); const data = Buffer.alloc(samples * 2); for (let i = 0; i < samples; i += 1) { const t = i / RATE; - const value = Math.sin(2 * Math.PI * FREQUENCY * t) * PEAK * gain(t); + const value = wave(t, frequency) * PEAK * gain(t); data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 32_767), i * 2); } const header = Buffer.alloc(44); @@ -59,7 +68,12 @@ async function main() { join(outDir, 'double-beep.wav'), render(0.6, (t) => (t < 0.1 ? envelope(t, 0, 0.1, 0.1) : envelope(t, 0.15, 0.6))), ); - console.log(`Wrote beep.wav and double-beep.wav to ${outDir}`); + const knockTap = (t: number, from: number) => envelope(t, from, from + 0.08); + await writeFile( + join(outDir, 'knock.wav'), + render(0.3, (t) => (t < 0.18 ? knockTap(t, 0) : knockTap(t, 0.18)), triangle, 180), + ); + console.log(`Wrote beep.wav, double-beep.wav and knock.wav to ${outDir}`); } main().catch((error: unknown) => { diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index f089e1ebe..0da814194 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -130,6 +130,8 @@ const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU', es: 'es-ES' } as const; const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' }; const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések', es: 'Notificaciones' } as const; +// The Timer format menu's custom format entry. +const TIMER_NEW_FORMAT = { en: 'Build a custom format', hu: 'Saját formátum készítése', es: 'Crear un formato personalizado' } as const; const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { @@ -1566,9 +1568,14 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan } /** - * 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 - * signals it would sound (timer-signals.web.ts) and the run checks them. + * The Timer from the Practice tab, on the website's free timer: idle, + * running, and over time on the school format; a British Parliamentary + * speech with points of information open; Public Forum prep; and the custom + * format sheet. The page's clock is Playwright's, so minutes pass at once; + * the web preview records the signals it would sound (timer-signals.web.ts) + * and what the Lock Screen would show (live-activity.web.ts), and the run + * checks both. The round is kept in the page's storage, so each run starts + * from a cleared one. */ async function shootTimerScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { const page = await context.newPage(); @@ -1576,27 +1583,67 @@ async function shootTimerScreens(context: BrowserContext, prefix: string, langua await pinSettings(page, language); await page.clock.install(); await page.goto(`${ORIGIN}/practice`); + await page.evaluate(() => { + localStorage.removeItem('debatelab.timer.session'); + localStorage.removeItem('debatelab.timer.formats'); + }); + await page.reload(); await page.getByTestId('practice-tool-timer').click({ timeout: 30_000 }); await page.getByTestId('timer-screen').waitFor({ timeout: 15_000 }); await settle(page); await shoot(page, `${prefix}-timer`); - // The first Standard speech is 4:00. After 1:14 it shows 2:46 and keeps running. + // The school format's first speech is 4:00. After 1:14 it shows 2:46 and keeps running. await page.getByTestId('timer-toggle').click(); await page.clock.fastForward('01:14'); await page.getByTestId('timer-time').filter({ hasText: '2:46' }).waitFor(); + const activity = await page.evaluate(() => (window as unknown as { __debatelabTimerActivity?: { state: { kind: string; pausedAt: number | null } | null } }).__debatelabTimerActivity); + if (activity?.state?.kind !== 'speech' || activity.state.pausedAt !== null) problems.push(`${prefix} timer: the Lock Screen state was ${JSON.stringify(activity)}`); await shoot(page, `${prefix}-timer-running`); - // Past the last minute and on to time up: one beep, then two. - await page.clock.fastForward('01:50'); + // Past the last minute and on into overtime: one beep, then two, and the clock counts on. + // runFor ticks through the marks; a jump (fastForward) reads as a return from the background, where only time up sounds. + await page.clock.runFor('01:50'); await page.getByTestId('timer-time').filter({ hasText: '0:56' }).waitFor(); await page.clock.fastForward('01:00'); - await page.getByTestId('timer-time').filter({ hasText: '0:00' }).waitFor(); + await page.getByTestId('timer-time').filter({ hasText: '+0:04' }).waitFor(); await page.waitForTimeout(100); - const signals = await page.evaluate(() => (window as unknown as { __debatelabTimerSignals?: { signal: string; sound: boolean }[] }).__debatelabTimerSignals ?? []); - const heard = signals.map((item) => item.signal).join(','); + const signals = async () => (await page.evaluate(() => (window as unknown as { __debatelabTimerSignals?: { signal: string }[] }).__debatelabTimerSignals ?? [])).map((item) => item.signal).join(','); + const heard = await signals(); if (heard !== 'beep,double-beep') throw new Error(`Timer signals were ${heard || 'none'}, expected beep,double-beep`); await shoot(page, `${prefix}-timer-time-up`); + await page.getByTestId('timer-toggle').click(); + + // British Parliamentary: 1:30 into the PM's speech, points of information are open and the knock sounded. + await page.getByTestId('timer-format').click(); + await page.getByTestId('timer-format-menu').getByRole('menuitem', { name: 'British Parliamentary' }).click(); + await page.getByTestId('timer-time').filter({ hasText: '7:00' }).waitFor(); + await page.getByTestId('timer-toggle').click(); + await page.clock.runFor('01:30'); + await page.getByTestId('timer-time').filter({ hasText: '5:30' }).waitFor(); + await page.getByTestId('timer-poi').waitFor(); + await page.waitForTimeout(100); + const withKnock = await signals(); + if (withKnock !== 'beep,double-beep,knock') problems.push(`${prefix} timer: BP signals were ${withKnock}, expected a knock when POIs opened`); + await shoot(page, `${prefix}-timer-bp-poi`); + await page.getByTestId('timer-toggle').click(); + + // Public Forum prep: the first team's bank runs while the speech waits. + await page.getByTestId('timer-format').click(); + await page.getByTestId('timer-format-menu').getByRole('menuitem', { name: 'Public Forum' }).click(); + await page.getByTestId('timer-prep-a').click(); + await page.clock.fastForward('00:40'); + await page.getByTestId('timer-prep-a').filter({ hasText: '2:20' }).waitFor(); + await page.getByTestId('timer-prep-a').scrollIntoViewIfNeeded(); + await shoot(page, `${prefix}-timer-prep`); + await page.getByTestId('timer-prep-a').click(); + + // The custom format sheet starts from the format on the timer. + await page.getByTestId('timer-format').click(); + await page.getByTestId('timer-format-menu').getByRole('menuitem', { name: TIMER_NEW_FORMAT[language as keyof typeof TIMER_NEW_FORMAT] }).click(); + await page.getByTestId('timer-format-sheet').waitFor({ timeout: 15_000 }); + await settle(page); + await shoot(page, `${prefix}-timer-format`); await page.close(); } @@ -2219,7 +2266,7 @@ async function main() { } if (want('timer')) { await shootTimerScreens(signedIn, prefix, language, problems); - shots += 3; + shots += 6; } if (want('crossfire')) { await shootCrossfireScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'crossfire')); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 70454b13a..67547a519 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -9,6 +9,7 @@ import { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { TimerRunner } from '@/components/timer-runner'; import { ToastHost, ToastProvider } from '@/components/toast'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; @@ -124,6 +125,7 @@ function Root() { {/* Live practice screens, full screen with no tab bar, as the live round in mock 05. */} + @@ -133,6 +135,8 @@ function Root() { + {/* The timer's signals and Live Activity keep going while the timer screen is closed. */} + ); diff --git a/apps/mobile/src/app/timer-format.tsx b/apps/mobile/src/app/timer-format.tsx new file mode 100644 index 000000000..e19923d4e --- /dev/null +++ b/apps/mobile/src/app/timer-format.tsx @@ -0,0 +1,222 @@ +import { CUSTOM_LIMITS, customToRoundFormat, parseCustomFormat, roundFormatToCustom, type CustomFormat, type CustomSegment } from '@debatelab/core/free-tools/custom-format'; +import { initialRoundState } from '@debatelab/core/free-tools/round-timer'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { MainActionButton, SecondaryButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { FormGroup, SwitchRow } from '@/components/form-rows'; +import { Icon } from '@/components/icon'; +import type { IconName } from '@/components/icon-paths'; +import { SegmentedControl } from '@/components/segmented-control'; +import { SheetScrollView } from '@/components/sheet'; +import { Text } from '@/components/text'; +import { haptics } from '@/lib/haptics'; +import { formatFor, formatName } from '@/lib/timer'; +import { getTimer, saveFormat, updateSession } from '@/lib/timer-store'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const clampSeconds = (value: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(value))); +const minutesText = (seconds: number) => String(Math.round((seconds / 60) * 100) / 100); + +/** + * Minutes as typed, as the website's MinutesInput: the field keeps its own + * text while it is edited, so it can be cleared and retyped, and reports + * whole seconds within bounds. 4 or 2.5 minutes are both fine. + */ +function MinutesField({ label, seconds, min, max, onChange, testID }: { + label: string; seconds: number; min: number; max: number; onChange: (seconds: number) => void; testID?: string; +}) { + const [text, setText] = useState(null); + return ( + setText(minutesText(seconds))} + onBlur={() => setText(null)} + onChangeText={(value) => { + setText(value); + const minutes = Number(value.replace(',', '.')); + if (value.trim() !== '' && Number.isFinite(minutes)) onChange(clampSeconds(minutes * 60, min, max)); + }} + testID={testID} + /> + ); +} + +function SmallIconButton({ icon, label, onPress, disabled }: { icon: IconName; label: string; onPress: () => void; disabled?: boolean }) { + const { colors } = useTheme(); + return ( + [styles.iconButton, { backgroundColor: colors.segmentTrack }, (pressed || disabled) && styles.dimmed]}> + + + ); +} + +const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +/** + * The website's custom format editor as a sheet: name the format, set prep + * time, and add, rename, time, reorder and remove speeches. "Use this format" + * saves it on the device and starts a fresh round with it. A new format + * starts from the one on the timer, as on the website. + */ +export default function TimerFormatSheet() { + const { t } = useTranslation(); + const tools = (key: string, options?: Record) => t(key, { ns: 'tools', ...options }); + const router = useRouter(); + const { colors } = useTheme(); + const { mode } = useLocalSearchParams<{ mode?: string }>(); + const [start] = useState(() => { + const { choice } = getTimer().session; + if (mode === 'edit' && choice.id === 'custom') return { draft: choice.custom, savedId: choice.savedId }; + const base = roundFormatToCustom(formatFor(choice)); + return { draft: { ...base, name: `${formatName(choice, formatFor(choice), t)} (${tools('custom.defaultName')})`.slice(0, CUSTOM_LIMITS.name) }, savedId: undefined }; + }); + const [draft, setDraft] = useState(start.draft); + + const setSegment = (index: number, change: Partial) => + setDraft((current) => ({ ...current, segments: current.segments.map((segment, i) => (i === index ? { ...segment, ...change } : segment)) })); + const move = (index: number, delta: number) => { + haptics.select(); + setDraft((current) => { + const segments = [...current.segments]; + const [moved] = segments.splice(index, 1); + segments.splice(index + delta, 0, moved); + return { ...current, segments }; + }); + }; + const remove = (index: number) => { + haptics.tap(); + setDraft((current) => ({ ...current, segments: current.segments.filter((_, i) => i !== index) })); + }; + const add = () => { + haptics.tap(); + setDraft((current) => ({ + ...current, + segments: [...current.segments, { label: tools('custom.newSpeech', { n: current.segments.length + 1 }), seconds: 240, kind: 'speech', side: 'a', poi: false }], + })); + }; + + // The same limits a shared link is held to on the website; the cleaned format is what is saved. + const cleaned = parseCustomFormat({ ...draft, name: draft.name.trim() || tools('custom.defaultName') }); + + const apply = () => { + if (!cleaned) return; + const id = start.savedId ?? newId(); + saveFormat({ id, format: cleaned }); + updateSession((current) => ({ ...current, choice: { id: 'custom', custom: cleaned, savedId: id }, round: initialRoundState(customToRoundFormat(cleaned)) })); + haptics.success(); + router.back(); + }; + + return ( + + + + {tools('custom.title')} + + router.back()} /> + + + setDraft((current) => ({ ...current, name }))} + testID="timer-format-name" + /> + setDraft((current) => ({ ...current, prepSeconds }))} + testID="timer-format-prep" + /> + + {draft.segments.map((segment, index) => ( + + + + {`${index + 1}.`} + + + move(index, -1)} disabled={index === 0} /> + move(index, 1)} disabled={index === draft.segments.length - 1} /> + remove(index)} disabled={draft.segments.length === 1} /> + + + + + setSegment(index, { label })} /> + + + setSegment(index, { seconds })} + /> + + + + {tools('custom.kind')} + + setSegment(index, { kind, ...(kind === 'speech' ? {} : { poi: false }) })} + /> + + {tools('custom.side')} + + setSegment(index, { side })} + /> + {segment.kind === 'speech' ? ( + + setSegment(index, { poi })} /> + + ) : null} + + ))} + + + {tools('custom.poiHint')} + + = CUSTOM_LIMITS.segments} testID="timer-format-add" /> + + + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 24, flexShrink: 1 }, + segment: { borderWidth: 2, borderRadius: radius.field, padding: 14, gap: 10 }, + segmentHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + segmentNumber: { fontSize: 16 }, + segmentActions: { flexDirection: 'row', gap: 8 }, + segmentFields: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' }, + labelField: { flex: 1 }, + minutesField: { width: 104 }, + groupLabel: { fontSize: 14, marginLeft: 4, marginBottom: -4 }, + iconButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + dimmed: { opacity: 0.4 }, + hint: { fontSize: 13.5, lineHeight: 19 }, +}); diff --git a/apps/mobile/src/app/timer.tsx b/apps/mobile/src/app/timer.tsx index 675823cee..46bfe6581 100644 --- a/apps/mobile/src/app/timer.tsx +++ b/apps/mobile/src/app/timer.tsx @@ -1,62 +1,76 @@ +import { BUILT_IN_FORMAT_IDS, FORMATS, formatDuration, type RoundSegment } from '@debatelab/core/free-tools/formats'; +import { + clockFace, + goToSegment, + initialRoundState, + isPrepRunning, + isSpeechRunning, + prepRemainingMs, + resetPrep, + resetSpeech, + togglePrep, + toggleSpeech, + type PrepSide, +} from '@debatelab/core/free-tools/round-timer'; import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AppState, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { ActionMenu } from '@/components/action-menu'; -import { Chip } from '@/components/chip'; +import { ActionMenu, type MenuItem } from '@/components/action-menu'; +import { TextButton } from '@/components/buttons'; import { Icon } from '@/components/icon'; import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; -import { - formatClock, - idleClock, - pauseClock, - remainingSeconds, - signalFor, - startClock, - TIMER_FORMATS, - type TimerClock, -} from '@/lib/timer'; -import { useKeepAwakeWhile, useTimerSignals } from '@/lib/timer-signals'; +import { confirmDestructive } from '@/lib/confirm'; +import { haptics } from '@/lib/haptics'; +import { useLayoutClass } from '@/lib/layout'; +import { formatFor, formatName, sideNameFor, speechStatus, type FormatChoice, type TimerSession, type Translate } from '@/lib/timer'; +import { useKeepAwakeWhile } from '@/lib/timer-signals'; +import { deleteSavedFormat, updateSession, useTimerStore } from '@/lib/timer-store'; import { useTheme } from '@/theme/use-theme'; const TICK_MS = 250; +// The website's side colours (sky for the first side, rose for the second), at the app's contrast. +const SIDE_COLORS = { + light: { a: '#0A6FF0', b: '#D6456B', both: '#5F708A' }, + dark: { a: '#4CA6FD', b: '#FF8AA8', both: '#8E9FB6' }, +} as const; + /** - * The debate timer, as the website's TimerPage: pick a format and a speech, - * then run its clock with a beep at one minute left and two at time up. Full - * screen with no tab bar, like the live Sparring round in mock 05. + * The debate timer, as the website's free round timer: every format it has + * (Public Forum to World Schools and Asian Parliamentary), each speech with + * its points-of-information window, prep banks for both sides, overtime and + * grace, and custom formats saved on the device. The clock lives in + * timer-store.ts and the signals in TimerRunner, so closing this screen + * leaves a running speech running, on the Lock Screen too. */ export default function TimerScreen() { const { t } = useTranslation(); + const tools: Translate = (key, options) => t(key, { ns: 'tools', ...options }); const router = useRouter(); const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); - const { colors } = useTheme(); - const [formatId, setFormatId] = useState(TIMER_FORMATS[0].id); - const [speechIndex, setSpeechIndex] = useState(0); - const format = TIMER_FORMATS.find((item) => item.id === formatId) ?? TIMER_FORMATS[0]; - const speech = format.speeches[speechIndex] ?? format.speeches[0]; - const [clock, setClock] = useState(() => idleClock(speech.minutes)); + const layout = useLayoutClass(); + const { colors, scheme } = useTheme(); + const { session, saved } = useTimerStore(); const [now, setNow] = useState(() => Date.now()); - const [sound, setSound] = useState(true); - const [formatMenu, setFormatMenu] = useState(false); - const signal = useTimerSignals(); + const [menu, setMenu] = useState(false); - const remaining = remainingSeconds(clock, now); - const running = clock.startedAt !== null && remaining > 0; - const finished = remaining === 0; - const total = speech.minutes * 60; - useKeepAwakeWhile(running); + const { round, muted, choice } = session; + const { format, segment, elapsed, remainingMs, overtime, inGrace, poi } = speechStatus(session, now); + const speechRunning = isSpeechRunning(round); + const prepRunning = isPrepRunning(round, 'a') || isPrepRunning(round, 'b'); + useKeepAwakeWhile(speechRunning || prepRunning); - // The clock reads the time from its start timestamp; the tick only redraws it. + // The clock reads the time from timestamps; the tick only redraws it. useEffect(() => { - if (clock.startedAt === null) return; + setNow(Date.now()); + if (!speechRunning && !prepRunning) return; const timer = setInterval(() => setNow(Date.now()), TICK_MS); - // Coming back from the background redraws at once, with any signal that came due. const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') setNow(Date.now()); }); @@ -64,55 +78,238 @@ export default function TimerScreen() { clearInterval(timer); subscription.remove(); }; - }, [clock.startedAt]); + }, [speechRunning, prepRunning]); - // Signals fire as the time left crosses a threshold, also when a pause lands on it, and the clock stops at zero. - const lastRemaining = useRef(remaining); - useEffect(() => { - const previous = lastRemaining.current; - lastRemaining.current = remaining; - const due = signalFor(previous, remaining, clock); - if (due) signal(due, sound); - if (remaining === 0 && clock.startedAt !== null) setClock((current) => pauseClock(current, Date.now())); - }, [clock, remaining, signal, sound]); - - const selectSpeech = (index: number, formatSpeeches = format.speeches) => { - const next = formatSpeeches[index]; - if (!next) return; - setSpeechIndex(index); - setClock(idleClock(next.minutes)); + const change = (next: (current: TimerSession) => TimerSession) => { + updateSession(next); setNow(Date.now()); }; - const toggle = () => { - const at = Date.now(); - setNow(at); - if (finished) { - // At time up the main button starts the next speech, or sets the last one up again. - const next = format.speeches[speechIndex + 1]; - if (next) { - setSpeechIndex(speechIndex + 1); - setClock(startClock(idleClock(next.minutes), at)); - } else { - setClock(idleClock(speech.minutes)); - } - return; - } - // TODO(live-activity): start a Live Activity here, from the same timestamps, once the dev build lands. - setClock((current) => (current.startedAt === null ? startClock(current, at) : pauseClock(current, at))); + haptics.tap(); + change((current) => ({ ...current, round: toggleSpeech(current.round, Date.now()) })); + }; + const reset = () => change((current) => ({ ...current, round: resetSpeech(current.round) })); + const go = (index: number) => { + if (index !== round.segmentIndex) haptics.select(); + change((current) => ({ ...current, round: goToSegment(current.round, formatFor(current.choice), index, Date.now()) })); }; + const prep = (side: PrepSide) => { + haptics.tap(); + change((current) => ({ ...current, round: togglePrep(current.round, side, Date.now()) })); + }; + const choose = (next: FormatChoice) => change((current) => ({ ...current, choice: next, round: initialRoundState(formatFor(next)) })); - const reset = () => { - setClock(idleClock(speech.minutes)); - setNow(Date.now()); + const name = formatName(choice, format, t); + const savedId = choice.id === 'custom' ? choice.savedId : undefined; + const menuItems: MenuItem[] = [ + ...BUILT_IN_FORMAT_IDS.map((id) => ({ + key: `builtin:${id}`, + label: formatName({ id }, FORMATS[id], t), + icon: choice.id === id ? ('check' as const) : undefined, + })), + ...saved.map((item) => ({ key: `saved:${item.id}`, label: item.format.name, icon: item.id === savedId ? ('check' as const) : undefined })), + { key: 'new', label: tools('custom.open'), icon: 'plus' }, + ...(choice.id === 'custom' ? [{ key: 'edit', label: t('mobile.timer.editFormat'), icon: 'pencil' as const }] : []), + ...(savedId ? [{ key: 'delete', label: t('mobile.timer.deleteFormat'), icon: 'trash' as const, destructive: true }] : []), + ]; + const onMenu = async (key: string) => { + if (key.startsWith('builtin:')) { + const id = BUILT_IN_FORMAT_IDS.find((entry) => `builtin:${entry}` === key); + if (id && choice.id !== id) choose({ id }); + } else if (key.startsWith('saved:')) { + const item = saved.find((entry) => `saved:${entry.id}` === key); + if (item && item.id !== savedId) choose({ id: 'custom', custom: item.format, savedId: item.id }); + } else if (key === 'new' || key === 'edit') { + router.push({ pathname: '/timer-format', params: { mode: key } }); + } else if (key === 'delete' && savedId) { + const confirmed = await confirmDestructive({ + title: t('mobile.timer.deleteFormatTitle', { name }), + message: t('mobile.timer.deleteFormatMessage'), + confirm: t('common.delete'), + cancel: t('common.cancel'), + }); + if (!confirmed) return; + deleteSavedFormat(savedId); + // The round keeps its format; it is just no longer saved. + change((current) => (current.choice.id === 'custom' ? { ...current, choice: { id: 'custom', custom: current.choice.custom } } : current)); + } }; - const ringSize = Math.round(Math.min(width - 72, height * 0.36, 320)); - const warning = remaining < 30 && remaining > 0; - const status = finished ? t('timer.timeUp') : running ? t('timer.speaking') : t('timer.paused'); - const hasNext = speechIndex < format.speeches.length - 1; - const mainLabel = finished && !hasNext ? t('mobile.timer.reset') : running ? t('timer.pause') : t('timer.start'); - const mainIcon = finished && !hasNext ? 'reset' : running ? 'pause' : 'play'; + const face = clockFace(remainingMs); + const secondsLeft = Math.max(0, Math.ceil(remainingMs / 1000)); + const warning = overtime || (secondsLeft <= 30 && (speechRunning || elapsed > 0)); + const status = overtime + ? tools(inGrace ? 'timer.grace' : 'timer.overtime') + : speechRunning ? tools('timer.running') : elapsed > 0 ? tools('timer.paused') : tools('timer.ready'); + const sideColors = SIDE_COLORS[scheme]; + const sideName = sideNameFor(choice, format, segment.side, t); + const isLast = round.segmentIndex >= format.segments.length - 1; + const hasPoi = format.segments.some((item) => item.poi); + const wideLayout = layout !== 'phone'; + const ringSize = Math.round(wideLayout ? Math.min(width * 0.36, height * 0.5, 400) : Math.min(width - 72, height * 0.34, 320)); + + const formatPill = ( + setMenu(true)} + testID="timer-format" + style={({ pressed }) => [styles.format, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> + + {name} + + + + ); + + const clock = ( + + + + + {sideName} + + + + + + {status} + + + {face.text} + + + {t('mobile.timer.ofTotal', { total: formatDuration(segment.seconds) })} + + + + + {poi && !overtime ? ( + + {poi === 'open' ? : } + + {poi === 'open' ? tools('timer.poiOpen') : tools('timer.protected')} + + + ) : null} + {segment.kind === 'reply' ? ( + + + {tools('timer.noPoiInReply')} + + + ) : null} + + {segment.poi ? : null} + + ); + + const speeches = ( + + {format.segments.map((item, index) => { + const selected = index === round.segmentIndex; + return ( + go(index)} + testID={`timer-speech-${index}`} + style={({ pressed }) => [ + styles.speech, + { borderColor: selected ? colors.primaryFill : colors.border, backgroundColor: selected ? colors.primaryFill : colors.card }, + pressed && styles.pressed, + ]}> + + {item.short} + + + + {formatDuration(item.seconds)} + + + ); + })} + + ); + + const prepBanks = format.prepSeconds > 0 ? ( + + + {(['a', 'b'] as const).map((side) => { + const left = prepRemainingMs(round, side, now); + const running = isPrepRunning(round, side); + return ( + prep(side)} + testID={`timer-prep-${side}`} + style={({ pressed }) => [ + styles.prep, + { borderColor: running ? colors.primaryFill : colors.border, backgroundColor: running ? colors.tile : colors.card }, + (pressed || left === 0) && styles.pressed, + ]}> + + {tools('timer.prepFor', { side: sideNameFor(choice, format, side, t) })} + + + {clockFace(left).text} + + + {left === 0 ? tools('timer.prepUsed') : running ? tools('timer.pausePrep') : tools('timer.startPrep')} + + + ); + })} + + change((current) => ({ ...current, round: resetPrep(current.round, formatFor(current.choice)) }))} /> + + ) : null; + + const signals = ( + + + + + {t('timer.signals')} + + + {hasPoi ? ( + + {`${t('mobile.timer.poiSignal')}: ${t('mobile.timer.knock')}`} + + ) : null} + + {`${t('timer.oneMinLeft')}: ${t('timer.oneBeep')}`} + + + {`${t('timer.timeUpSignal')}: ${t('timer.twoBeeps')}`} + + + {t('mobile.timer.keepsRunning')} + + + ); return ( @@ -120,110 +317,122 @@ export default function TimerScreen() { left={ router.back()} testID="timer-close" />} right={ setSound((value) => !value)} + icon={muted ? 'volumeOff' : 'volume'} + label={muted ? t('timer.enableSound') : t('timer.disableSound')} + onPress={() => change((current) => ({ ...current, muted: !current.muted }))} testID="timer-sound" /> } - title={t(speech.labelKey)} - subtitle={t('mobile.timer.speechOf', { n: speechIndex + 1, of: format.speeches.length })} + title={segment.label} + subtitle={t('mobile.timer.speechOf', { n: round.segmentIndex + 1, of: format.segments.length })} /> - setFormatMenu(true)} - testID="timer-format" - style={({ pressed }) => [styles.format, { borderColor: colors.border, backgroundColor: colors.input }, pressed && styles.pressed]}> - - {t(format.nameKey)} - - - - - - 0 ? remaining / total : 0} warning={warning || finished}> - - {status} - - - {formatClock(remaining)} - - - {t('mobile.timer.ofTotal', { total: formatClock(total) })} - - - - - - {format.speeches.map((item, index) => ( - selectSpeech(index)} - testID={`timer-speech-${item.id}`} - /> - ))} - - - - - - - {t('timer.signals')} - + {formatPill} + {wideLayout ? ( + + {clock} + + {speeches} + {prepBanks} + {signals} + - - {`${t('timer.oneMinLeft')}: ${t('timer.oneBeep')}`} - - - {`${t('timer.timeUpSignal')}: ${t('timer.twoBeeps')}`} - - + ) : ( + <> + {clock} + {speeches} + {prepBanks} + {signals} + + )} - - - selectSpeech(speechIndex + 1)} disabled={!hasNext} testID="timer-next" /> + + + go(round.segmentIndex + 1)} disabled={isLast} testID="timer-next" /> ({ key: item.id, label: t(item.nameKey), icon: item.id === formatId ? 'check' : undefined }))} + items={menuItems} cancelLabel={t('common.cancel')} - onClose={() => setFormatMenu(false)} - onSelect={(key) => { - const next = TIMER_FORMATS.find((item) => item.id === key); - if (!next) return; - setFormatId(next.id); - selectSpeech(0, next.speeches); - }} + onClose={() => setMenu(false)} + onSelect={(key) => void onMenu(key)} + testID="timer-format-menu" /> ); } +/** The protected and open stretches of a speech, with a marker where the speaker is, as the website's PoiStrip. */ +function PoiStrip({ segment, elapsed }: { segment: RoundSegment; elapsed: number }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (!segment.poi) return null; + const total = segment.seconds; + const opens = (segment.poi.opensAt / total) * 100; + const closes = (segment.poi.closesAt / total) * 100; + const position = Math.min(100, (elapsed / 1000 / total) * 100); + return ( + + + + + + + 0:00 + + {t('timer.poiWindow', { ns: 'tools', from: formatDuration(segment.poi.opensAt), to: formatDuration(segment.poi.closesAt) })} + + {formatDuration(total)} + + + ); +} + const styles = StyleSheet.create({ root: { flex: 1 }, - content: { alignItems: 'center', gap: 22, paddingHorizontal: 20, paddingTop: 14 }, - format: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, minHeight: 38 }, - formatLabel: { fontSize: 15 }, - pressed: { opacity: 0.7 }, + content: { alignItems: 'center', gap: 20, paddingHorizontal: 20, paddingTop: 14 }, + format: { flexDirection: 'row', alignItems: 'center', gap: 6, borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, minHeight: 38, maxWidth: '100%' }, + formatLabel: { fontSize: 15, flexShrink: 1 }, + pressed: { opacity: 0.6 }, + clockColumn: { alignItems: 'center', gap: 12, width: '100%' }, + sideLine: { flexDirection: 'row', alignItems: 'center', gap: 7, maxWidth: '100%' }, + sideDot: { width: 10, height: 10, borderRadius: 5 }, + sideText: { fontSize: 15, flexShrink: 1 }, status: { fontSize: 14, lineHeight: 19 }, time: { letterSpacing: -2, fontVariant: ['tabular-nums'] }, + pills: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8, minHeight: 30 }, + pill: { flexDirection: 'row', alignItems: 'center', gap: 5, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 }, + pillText: { fontSize: 14 }, + strip: { width: '100%', maxWidth: 440, gap: 5 }, + stripTrack: { height: 12, borderRadius: 6, overflow: 'hidden' }, + stripOpen: { position: 'absolute', top: 0, bottom: 0, opacity: 0.75 }, + stripMarker: { position: 'absolute', top: 0, bottom: 0, width: 4, marginLeft: -2, borderRadius: 2 }, + stripLabels: { flexDirection: 'row', justifyContent: 'space-between' }, + stripText: { fontSize: 12.5, fontVariant: ['tabular-nums'] }, speechRow: { flexGrow: 0, alignSelf: 'stretch', marginHorizontal: -20 }, - // Centred when they fit, as on iPad; scrolling from the start when they don't. + // Centred when they fit; scrolling from the start when they don't. speeches: { gap: 8, paddingHorizontal: 20, flexGrow: 1, justifyContent: 'center' }, + speechGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + speech: { minWidth: 72, minHeight: 52, borderWidth: 2, borderRadius: 12, paddingHorizontal: 10, paddingVertical: 6, alignItems: 'center', justifyContent: 'center', gap: 2 }, + speechShort: { fontSize: 14.5, textAlign: 'center' }, + speechMeta: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + speechDot: { width: 7, height: 7, borderRadius: 4 }, + speechTime: { fontSize: 12.5, fontVariant: ['tabular-nums'] }, + prepGroup: { width: '100%', maxWidth: 520, alignItems: 'center', gap: 6 }, + prepRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch' }, + prep: { flex: 1, borderWidth: 2, borderRadius: 14, paddingVertical: 10, paddingHorizontal: 8, alignItems: 'center', gap: 2 }, + prepLabel: { fontSize: 13.5 }, + prepTime: { fontSize: 24, fontVariant: ['tabular-nums'] }, + prepAction: { fontSize: 13.5 }, signals: { alignSelf: 'center', maxWidth: 520, width: '100%', borderWidth: 2, borderRadius: 14, paddingHorizontal: 14, paddingVertical: 11, gap: 3 }, signalTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2 }, signalText: { fontSize: 14, lineHeight: 19 }, + keepsRunning: { marginTop: 4 }, + wide: { flexDirection: 'row', alignItems: 'flex-start', gap: 32, width: '100%', maxWidth: 1100 }, + wideSide: { flex: 1, gap: 18, paddingTop: 30 }, controls: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-evenly', paddingTop: 12 }, }); diff --git a/apps/mobile/src/components/timer-runner.tsx b/apps/mobile/src/components/timer-runner.tsx new file mode 100644 index 000000000..cba9edd80 --- /dev/null +++ b/apps/mobile/src/components/timer-runner.tsx @@ -0,0 +1,110 @@ +import { cueBetween, elapsedMs, isSpeechRunning, settlePrep } from '@debatelab/core/free-tools/round-timer'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AppState } from 'react-native'; + +import { syncTimerActivity } from '@/lib/live-activity'; +import { formatFor, isActive, sideNameFor, timerActivityState, type TimerSession } from '@/lib/timer'; +import { useTimerSignals } from '@/lib/timer-signals'; +import { loadTimer, updateSession, useTimerStore } from '@/lib/timer-store'; + +const TICK_MS = 250; +/** A longer gap between readings means the app was in the background. */ +const GAP_MS = 2_000; +/** How long the signals stay after the last clock stops, so a time-up signal plays to its end. */ +const LINGER_MS = 3_000; + +/** + * Runs the timer from the root layout, so a speech keeps its signals while + * the student reads a case and the Lock Screen keeps its clock after the + * timer closes. It draws nothing. The signals mount only while a clock runs + * (and a moment after, so time up plays out), so opening the app never + * touches the audio session. + */ +export function TimerRunner() { + const { t, i18n } = useTranslation(); + const { loaded, session } = useTimerStore(); + const format = useMemo(() => formatFor(session.choice), [session.choice]); + + useEffect(() => { + void loadTimer(); + }, []); + + // The Lock Screen follows every change of the round, and the language. + useEffect(() => { + if (!loaded) return; + const tools = (key: string, options?: Record) => t(key, { ns: 'tools', ...options }); + const sideName = (side: 'a' | 'b' | 'both') => sideNameFor(session.choice, format, side, t); + const idle = { idleTitle: t('mobile.timer.widgetTitle'), idleLine: t('mobile.timer.widgetIdle') }; + const state = timerActivityState(session, Date.now(), { + sideName, + prepLine: (side) => tools('timer.prepFor', { side: sideName(side) }), + labels: { + speaking: tools('timer.running'), + paused: tools('timer.paused'), + overtime: tools('timer.overtime'), + prep: tools('timer.prepGroup'), + ...idle, + }, + }); + syncTimerActivity(state, idle); + }, [format, i18n.language, loaded, session, t]); + + const active = loaded && isActive(session.round); + const [lingering, setLingering] = useState(false); + useEffect(() => { + if (active) { + setLingering(true); + return; + } + const timer = setTimeout(() => setLingering(false), LINGER_MS); + return () => clearTimeout(timer); + }, [active]); + + return active || lingering ? : null; +} + +/** + * Cues as a speech crosses its marks (a knock when points of information + * open or close, a beep at one minute left, two at time), and an exhausted + * prep bank stopping at zero with the time-up signal. + */ +function ClockSignals({ session }: { session: TimerSession }) { + const signal = useTimerSignals(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') setNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, []); + + const format = useMemo(() => formatFor(session.choice), [session.choice]); + const segment = format.segments[session.round.segmentIndex] ?? format.segments[0]; + const segmentKey = `${format.id}:${format.name}:${session.round.segmentIndex}`; + const elapsed = elapsedMs(session.round.speech, now); + // Cues sound on crossings, so reopening the app mid-speech does not replay the ones already passed. + // After a gap in the background only time up sounds, as the app's timer always did. + const last = useRef<{ key: string; elapsed: number; at: number } | null>(null); + useEffect(() => { + const previous = last.current; + last.current = { key: segmentKey, elapsed, at: now }; + if (!previous || previous.key !== segmentKey || !isSpeechRunning(session.round)) return; + const cue = cueBetween(segment, previous.elapsed, elapsed); + if (!cue || (now - previous.at > GAP_MS && cue !== 'double-beep')) return; + signal(cue, !session.muted); + }, [elapsed, now, segment, segmentKey, session.muted, session.round, signal]); + + useEffect(() => { + if (settlePrep(session.round, now) === session.round) return; + signal('double-beep', !session.muted); + updateSession((current) => ({ ...current, round: settlePrep(current.round, Date.now()) })); + }, [now, session.muted, session.round, signal]); + + return null; +} diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index b2977448b..3fe5c8a7a 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -20,6 +20,10 @@ import { initReactI18next } from 'react-i18next'; import en from '../../../../src/locales/en.json'; import es from '../../../../src/locales/es.json'; import hu from '../../../../src/locales/hu.json'; +// The free tools' catalogs (timer, speech-time calculator, motion generator), as the website's `tools` namespace. +import toolsEn from '../../../../src/locales/tools/en.json'; +import toolsEs from '../../../../src/locales/tools/es.json'; +import toolsHu from '../../../../src/locales/tools/hu.json'; /** The device language when DebateLab has it, otherwise English. Signed in, the account's choice wins (see settings.tsx). */ export function deviceLanguage(): AppLanguage { @@ -29,7 +33,11 @@ export function deviceLanguage(): AppLanguage { // Bundled resources make init synchronous; the returned promise cannot reject. void i18n.use(initReactI18next).init({ - resources: { en: { translation: en }, es: { translation: es }, hu: { translation: hu } }, + resources: { + en: { translation: en, tools: toolsEn }, + es: { translation: es, tools: toolsEs }, + hu: { translation: hu, tools: toolsHu }, + }, lng: deviceLanguage(), fallbackLng: 'en', interpolation: { escapeValue: false }, diff --git a/apps/mobile/src/lib/live-activity.ts b/apps/mobile/src/lib/live-activity.ts new file mode 100644 index 000000000..48beb240f --- /dev/null +++ b/apps/mobile/src/lib/live-activity.ts @@ -0,0 +1,88 @@ +import type { LiveActivity } from 'expo-widgets'; + +import { nativeCapabilities } from './native-capabilities'; +import type { TimerActivityState } from './timer'; + +import type { TimerWidgetProps } from '@/widgets/timer-widgets'; + +// The timer's Live Activity (Lock Screen and Dynamic Island) and its +// lock-screen widget. Both need the development build with an App Group +// (DEBATELAB_APP_GROUP), so everywhere else, Expo Go included, every call +// here does nothing and the widgets module is never loaded. + +type WidgetsModule = typeof import('@/widgets/timer-widgets'); + +let widgets: WidgetsModule | null | undefined; +let activity: LiveActivity | null = null; +let adopted = false; +let lastKey = ''; + +function load(): WidgetsModule | null { + if (widgets !== undefined) return widgets; + widgets = null; + if (!nativeCapabilities().liveActivity) return null; + try { + // Loaded on first use: its top level registers the layouts with the extension. + // eslint-disable-next-line @typescript-eslint/no-require-imports + widgets = require('@/widgets/timer-widgets') as WidgetsModule; + } catch (cause) { + console.warn('Timer widgets are unavailable:', cause instanceof Error ? cause.message : cause); + } + return widgets; +} + +function warn(what: string, cause: unknown) { + console.warn(`Could not ${what} the timer's Live Activity:`, cause instanceof Error ? cause.message : cause); +} + +/** + * Shows `state` on the Lock Screen and in the Dynamic Island, or ends the + * activity when it is null. An activity left by an earlier launch is taken + * over, and any extra ones are ended, so one timer never shows twice. + */ +export function syncTimerActivity(state: TimerActivityState | null, idle: Omit, now = Date.now()) { + const module = load(); + if (!module) return; + const key = JSON.stringify([state, idle]); + if (key === lastKey) return; + lastKey = key; + + if (!adopted) { + adopted = true; + try { + const [first, ...extra] = module.timerActivity.getInstances(); + activity = first ?? null; + extra.forEach((instance) => instance.end('immediate').catch((cause: unknown) => warn('end', cause))); + } catch (cause) { + warn('find', cause); + } + } + + // The system de-emphasises the activity at the limit; the layout turns to overtime then. + const staleDate = state && state.pausedAt === null && state.endsAt > now ? new Date(state.endsAt) : undefined; + if (!state) { + const ending = activity; + activity = null; + ending?.end('immediate').catch((cause: unknown) => warn('end', cause)); + } else if (activity) { + activity.update(state, staleDate).catch((cause: unknown) => warn('update', cause)); + } else { + try { + activity = module.timerActivity.start(state, 'debatelab://timer', staleDate); + } catch (cause) { + // Live Activities switched off in Settings, or an iOS before 16.2. + warn('start', cause); + } + } + + try { + const props: TimerWidgetProps = { state, ...idle }; + // A speech that will run past its limit gets a second entry at the limit, when the widget turns to overtime. + module.timerWidget.updateTimeline([ + { date: new Date(now), props }, + ...(staleDate && state?.kind === 'speech' ? [{ date: staleDate, props }] : []), + ]); + } catch (cause) { + console.warn('Could not update the timer widget:', cause instanceof Error ? cause.message : cause); + } +} diff --git a/apps/mobile/src/lib/live-activity.web.ts b/apps/mobile/src/lib/live-activity.web.ts new file mode 100644 index 000000000..9b82c59ef --- /dev/null +++ b/apps/mobile/src/lib/live-activity.web.ts @@ -0,0 +1,10 @@ +import type { TimerActivityState } from './timer'; + +/** + * The web preview has no Live Activities. It keeps the last state on + * `window`, so the screenshot script can check what the Lock Screen would + * show. The phone app uses live-activity.ts. + */ +export function syncTimerActivity(state: TimerActivityState | null, idle: { idleTitle: string; idleLine: string }) { + (window as unknown as { __debatelabTimerActivity?: unknown }).__debatelabTimerActivity = { state, ...idle }; +} diff --git a/apps/mobile/src/lib/timer-signals.ts b/apps/mobile/src/lib/timer-signals.ts index 8e259bf36..a0146b4de 100644 --- a/apps/mobile/src/lib/timer-signals.ts +++ b/apps/mobile/src/lib/timer-signals.ts @@ -8,13 +8,14 @@ import type { TimerSignal } from './timer'; const KEEP_AWAKE_TAG = 'debate-timer'; /** - * The Timer's signals: the website's beeps from bundled sounds + * The Timer's signals: the website's beeps and knock from bundled sounds * (assets/sounds, see its README) and a haptic tap with each. Muting the * sound keeps the tap, so a muted timer still says when time is up. */ export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void { const beep = useAudioPlayer(require('@/assets/sounds/beep.wav')); const doubleBeep = useAudioPlayer(require('@/assets/sounds/double-beep.wav')); + const knock = useAudioPlayer(require('@/assets/sounds/knock.wav')); useEffect(() => { // A timer is heard in a round even with the ringer off. @@ -23,19 +24,21 @@ export function useTimerSignals(): (signal: TimerSignal, sound: boolean) => void }, []); return useCallback((signal: TimerSignal, sound: boolean) => { - const haptic = signal === 'beep' - ? Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) - : Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + const haptic = signal === 'knock' + ? Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) + : signal === 'beep' + ? Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) + : Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); haptic.catch(() => {}); if (!sound) return; - const player = signal === 'beep' ? beep : doubleBeep; + const player = signal === 'knock' ? knock : signal === 'beep' ? beep : doubleBeep; try { player.seekTo(0).catch(() => {}); player.play(); } catch (cause) { console.warn('Could not play the timer signal:', cause instanceof Error ? cause.message : cause); } - }, [beep, doubleBeep]); + }, [beep, doubleBeep, knock]); } /** A light tap when the student's turn starts in a live round, so they know without looking. */ diff --git a/apps/mobile/src/lib/timer-store.ts b/apps/mobile/src/lib/timer-store.ts new file mode 100644 index 000000000..62816685d --- /dev/null +++ b/apps/mobile/src/lib/timer-store.ts @@ -0,0 +1,90 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSyncExternalStore } from 'react'; + +import { initialSession, parseSavedFormats, parseSession, serializeSession, upsertSavedFormat, type SavedFormat, type TimerSession } from './timer'; + +// The timer's round and the custom formats saved on this device, as the +// website keeps its round in localStorage. Not tied to an account: a timer +// holds no debate content. The screen, the format sheet and the Live +// Activity read the same state. + +const SESSION_KEY = 'debatelab.timer.session'; +const FORMATS_KEY = 'debatelab.timer.formats'; + +interface TimerStoreState { + loaded: boolean; + session: TimerSession; + saved: SavedFormat[]; +} + +let state: TimerStoreState = { loaded: false, session: initialSession(), saved: [] }; +const listeners = new Set<() => void>(); +let loading: Promise | null = null; + +function emit() { + listeners.forEach((listener) => listener()); +} + +function persist(key: string, value: unknown) { + AsyncStorage.setItem(key, JSON.stringify(value)).catch((cause: unknown) => + // The timer still works; it just will not survive a restart. + console.warn('Could not keep the timer:', cause instanceof Error ? cause.message : cause)); +} + +async function readJson(key: string): Promise { + try { + return JSON.parse((await AsyncStorage.getItem(key)) ?? 'null'); + } catch { + return null; + } +} + +/** Reads the stored round and formats once; later calls share the first read. */ +export function loadTimer(): Promise { + loading ??= (async () => { + const [session, saved] = await Promise.all([readJson(SESSION_KEY), readJson(FORMATS_KEY)]); + // A change made before the read finished (a tap on a fresh screen) wins over the stored round. + state = { + loaded: true, + session: state.loaded ? state.session : parseSession(session) ?? state.session, + saved: state.loaded ? state.saved : parseSavedFormats(saved), + }; + emit(); + })(); + return loading; +} + +export function getTimer(): TimerStoreState { + return state; +} + +export function updateSession(change: (current: TimerSession) => TimerSession) { + const next = change(state.session); + if (next === state.session) return; + state = { ...state, loaded: true, session: next }; + persist(SESSION_KEY, serializeSession(next)); + emit(); +} + +export function saveFormat(entry: SavedFormat) { + state = { ...state, saved: upsertSavedFormat(state.saved, entry) }; + persist(FORMATS_KEY, state.saved); + emit(); +} + +export function deleteSavedFormat(id: string) { + state = { ...state, saved: state.saved.filter((item) => item.id !== id) }; + persist(FORMATS_KEY, state.saved); + emit(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useTimerStore(): TimerStoreState { + return useSyncExternalStore(subscribe, getTimer, getTimer); +} diff --git a/apps/mobile/src/lib/timer.test.ts b/apps/mobile/src/lib/timer.test.ts index 15afc441c..8d681bb1f 100644 --- a/apps/mobile/src/lib/timer.test.ts +++ b/apps/mobile/src/lib/timer.test.ts @@ -1,79 +1,55 @@ +import { togglePrep, toggleSpeech } from '@debatelab/core/free-tools/round-timer'; import { describe, expect, it } from 'vitest'; -import { idleClock, pauseClock, remainingSeconds, signalBetween, signalFor, startClock, type TimerClock, type TimerSignal } from './timer'; +import { initialSession, parseSavedFormats, parseSession, serializeSession, timerActivityState, type TimerSession } from './timer'; const T0 = 1_000_000; - -describe('timer clock', () => { - it('shows the right time after a gap with no ticks, as after the app was in the background', () => { - const clock = startClock(idleClock(4), T0); - // No tick ran for 90 s; the time comes from the start timestamp alone. - expect(remainingSeconds(clock, T0 + 90_000)).toBe(150); - }); - - it('keeps the time that ran before a pause and stops at zero', () => { - let clock = startClock(idleClock(1), T0); - clock = pauseClock(clock, T0 + 20_500); - // Paused time does not count. - expect(remainingSeconds(clock, T0 + 600_000)).toBe(40); - clock = startClock(clock, T0 + 600_000); - expect(remainingSeconds(clock, T0 + 610_000)).toBe(30); - expect(remainingSeconds(clock, T0 + 900_000)).toBe(0); - // A finished clock does not start again. - expect(startClock(pauseClock(clock, T0 + 900_000), T0 + 900_000).startedAt).toBeNull(); +const text = { + sideName: (side: string) => side, + prepLine: (side: string) => `prep ${side}`, + labels: { speaking: 'Speaking', paused: 'Paused', overtime: 'Over time', prep: 'Prep', idleTitle: 'Timer', idleLine: 'Idle' }, +}; +const custom = { name: 'Club', prepSeconds: 120, segments: [{ label: 'Opening', seconds: 300, kind: 'speech' as const, side: 'a' as const, poi: true }] }; + +describe('stored timer session', () => { + it('reopens a running speech where it was, with its custom format', () => { + const running: TimerSession = { ...initialSession({ id: 'custom', custom, savedId: 'x1' }), muted: true }; + running.round = toggleSpeech(running.round, T0); + const reopened = parseSession(JSON.parse(JSON.stringify(serializeSession(running)))); + expect(reopened).toEqual(running); }); -}); -describe('timer signals', () => { - it('beeps once on reaching the last minute and twice at time up', () => { - expect(signalBetween(61, 60)).toBe('beep'); - expect(signalBetween(60, 59)).toBeNull(); - expect(signalBetween(1, 0)).toBe('double-beep'); - expect(signalBetween(0, 0)).toBeNull(); + it('refuses a round that does not fit its format and a format past the limits', () => { + const stored = serializeSession(initialSession({ id: 'bp' })) as { round: { segmentIndex: number } }; + expect(parseSession({ ...stored, round: { ...stored.round, segmentIndex: 8 } })).toBeNull(); + expect(parseSession({ ...stored, format: 'custom', custom: { ...custom, segments: [] } })).toBeNull(); + expect(parseSession({ ...stored, format: 'unknown' })).toBeNull(); }); - it('sounds only time up when a background gap crossed both thresholds', () => { - expect(signalBetween(150, 0)).toBe('double-beep'); - expect(signalBetween(150, 30)).toBe('beep'); + it('keeps the saved formats that parse and drops the rest', () => { + const saved = parseSavedFormats([{ id: 'a', format: custom }, { id: 'a', format: custom }, { id: 'b', format: { ...custom, prepSeconds: -1 } }, 'junk']); + expect(saved.map((item) => item.id)).toEqual(['a']); }); }); -describe('timer signals across a pause', () => { - /** Reads the clock as the screen does on each redraw, collecting the signals it sounds. */ - function reader(clock: TimerClock, now: number) { - let last = remainingSeconds(clock, now); - const heard: TimerSignal[] = []; - return { - heard, - read(next: TimerClock, at: number) { - const current = remainingSeconds(next, at); - const due = signalFor(last, current, next); - last = current; - if (due) heard.push(due); - }, - }; - } - - it('beeps once when a pause lands as 1:01 turns to 1:00, and not again on resume', () => { - let clock = startClock(idleClock(2), T0); - const screen = reader(clock, T0); - screen.read(clock, T0 + 59_200); // shows 1:01 - clock = pauseClock(clock, T0 + 60_100); // real time left is now 59.9 s, shown as 1:00 - screen.read(clock, T0 + 60_100); - clock = startClock(clock, T0 + 90_000); - screen.read(clock, T0 + 91_000); // 0:59 - expect(screen.heard).toEqual(['beep']); +describe('Live Activity clock', () => { + it('counts a running speech down from its virtual start to its limit', () => { + let session = initialSession({ id: 'bp' }); + expect(timerActivityState(session, T0, text)).toBeNull(); + session = { ...session, round: toggleSpeech(session.round, T0) }; + const state = timerActivityState(session, T0 + 90_000, text); + expect(state).toMatchObject({ kind: 'speech', title: 'PM', startedAt: T0, endsAt: T0 + 7 * 60_000, pausedAt: null }); }); - it('sounds nothing when a reset or another speech moves the time left', () => { - let clock = startClock(idleClock(2), T0); - const screen = reader(clock, T0); - screen.read(clock, T0 + 30_000); // 1:30 - // A fresh one-minute clock drops the reading from 1:30 to 1:00 without the speech running. - clock = idleClock(1); - screen.read(clock, T0 + 30_000); - clock = idleClock(4); - screen.read(clock, T0 + 30_000); - expect(screen.heard).toEqual([]); + it('stands still at the paused moment, and a running prep bank takes over', () => { + let session = initialSession({ id: 'pf' }); + session = { ...session, round: toggleSpeech(session.round, T0) }; + session = { ...session, round: toggleSpeech(session.round, T0 + 30_000) }; + expect(timerActivityState(session, T0 + 500_000, text)).toMatchObject({ startedAt: T0 + 470_000, pausedAt: T0 + 500_000, endsAt: T0 + 470_000 + 240_000 }); + + session = { ...session, round: togglePrep(session.round, 'b', T0 + 600_000) }; + const prep = timerActivityState(session, T0 + 660_000, text); + // PF prep is 3:00; a minute used leaves the bank ending two minutes on. + expect(prep).toMatchObject({ kind: 'prep', line: 'prep b', side: 'b', endsAt: T0 + 780_000, pausedAt: null }); }); }); diff --git a/apps/mobile/src/lib/timer.ts b/apps/mobile/src/lib/timer.ts index 6850b5177..94a6e200a 100644 --- a/apps/mobile/src/lib/timer.ts +++ b/apps/mobile/src/lib/timer.ts @@ -1,130 +1,224 @@ /** - * The debate timer, as the website's TimerPage (src/pages/TimerPage.tsx): the - * same formats, speeches and signals. The clock keeps timestamps, never counts - * ticks, so it shows the right time after the app has been in the background. + * The debate timer, as the website's free round timer + * (src/components/free-tools/DebateTimer.tsx and timer-session.ts): the same + * formats, speeches, prep banks, points of information and custom formats, + * from @debatelab/core/free-tools. A speech counts up and runs into overtime; + * prep banks count down and stop at zero. Every clock is a timestamp, never a + * count of ticks, so it is right after the app comes back from the background + * or is opened again. */ +import { customToRoundFormat, parseCustomFormat, type CustomFormat } from '@debatelab/core/free-tools/custom-format'; +import { FORMATS, isBuiltInFormatId, type BuiltInFormatId, type RoundFormat, type RoundSegment } from '@debatelab/core/free-tools/formats'; +import { + elapsedMs, + initialRoundState, + isPrepRunning, + isSpeechRunning, + parseRoundState, + prepRemainingMs, + segmentStatus, + type PrepSide, + type RoundState, + type TimerCue, +} from '@debatelab/core/free-tools/round-timer'; -export interface TimerSpeech { - id: string; - labelKey: string; - minutes: number; +export type TimerSignal = TimerCue; + +/** A built-in format, or a custom one; `savedId` names the saved custom format it came from, if any. */ +export type FormatChoice = { id: BuiltInFormatId } | { id: 'custom'; custom: CustomFormat; savedId?: string }; + +export interface TimerSession { + choice: FormatChoice; + round: RoundState; + muted: boolean; } -export interface TimerFormat { +/** A custom format the student saved on this device. */ +export interface SavedFormat { id: string; - nameKey: string; - speeches: TimerSpeech[]; -} - -export const TIMER_FORMATS: TimerFormat[] = [ - { - id: 'standard', - nameKey: 'timer.formatStandard', - speeches: [ - { id: 'intro', labelKey: 'timer.speeches.standard.intro', minutes: 4 }, - { id: 'r1', labelKey: 'timer.speeches.standard.r1', minutes: 6 }, - { id: 'r2', labelKey: 'timer.speeches.standard.r2', minutes: 6 }, - { id: 'conc', labelKey: 'timer.speeches.standard.conc', minutes: 3 }, - ], - }, - { - id: 'ld', - nameKey: 'timer.formatLD', - speeches: [ - { id: 'ac', labelKey: 'timer.speeches.ld.ac', minutes: 6 }, - { id: 'nc', labelKey: 'timer.speeches.ld.nc', minutes: 7 }, - { id: 'ar1', labelKey: 'timer.speeches.ld.ar1', minutes: 4 }, - { id: 'nr', labelKey: 'timer.speeches.ld.nr', minutes: 6 }, - { id: 'ar2', labelKey: 'timer.speeches.ld.ar2', minutes: 3 }, - ], - }, - { - id: 'pf', - nameKey: 'timer.formatPF', - speeches: [ - { id: 'team1_c', labelKey: 'timer.speeches.pf.team1c', minutes: 4 }, - { id: 'team2_c', labelKey: 'timer.speeches.pf.team2c', minutes: 4 }, - { id: 'team1_r', labelKey: 'timer.speeches.pf.team1r', minutes: 4 }, - { id: 'team2_r', labelKey: 'timer.speeches.pf.team2r', minutes: 4 }, - { id: 'team1_s', labelKey: 'timer.speeches.pf.team1s', minutes: 3 }, - { id: 'team2_s', labelKey: 'timer.speeches.pf.team2s', minutes: 3 }, - { id: 'team1_ff', labelKey: 'timer.speeches.pf.team1ff', minutes: 2 }, - { id: 'team2_ff', labelKey: 'timer.speeches.pf.team2ff', minutes: 2 }, - ], - }, - { - id: 'policy', - nameKey: 'timer.formatPolicy', - speeches: [ - { id: '1ac', labelKey: 'timer.speeches.policy.1ac', minutes: 8 }, - { id: '1nc', labelKey: 'timer.speeches.policy.1nc', minutes: 8 }, - { id: '2ac', labelKey: 'timer.speeches.policy.2ac', minutes: 8 }, - { id: '2nc', labelKey: 'timer.speeches.policy.2nc', minutes: 8 }, - { id: '1nr', labelKey: 'timer.speeches.policy.1nr', minutes: 5 }, - { id: '1ar', labelKey: 'timer.speeches.policy.1ar', minutes: 5 }, - { id: '2nr', labelKey: 'timer.speeches.policy.2nr', minutes: 5 }, - { id: '2ar', labelKey: 'timer.speeches.policy.2ar', minutes: 5 }, - ], - }, -]; - -/** One speech's clock: its length, when it last started running, and the time it ran before that. */ -export interface TimerClock { - durationMs: number; - /** When the clock last started; null while it is stopped. */ - startedAt: number | null; - /** Time that ran before the last start, across pauses. */ - elapsedBeforeMs: number; -} - -export function idleClock(minutes: number): TimerClock { - return { durationMs: minutes * 60_000, startedAt: null, elapsedBeforeMs: 0 }; -} - -export function elapsedMs(clock: TimerClock, now: number): number { - const running = clock.startedAt === null ? 0 : Math.max(0, now - clock.startedAt); - return Math.min(clock.durationMs, clock.elapsedBeforeMs + running); -} - -/** Whole seconds left, rounded up as the website shows them: 0:01 until the last moment. */ -export function remainingSeconds(clock: TimerClock, now: number): number { - return Math.max(0, Math.ceil((clock.durationMs - elapsedMs(clock, now)) / 1000)); -} - -export function startClock(clock: TimerClock, now: number): TimerClock { - if (clock.startedAt !== null || elapsedMs(clock, now) >= clock.durationMs) return clock; - return { ...clock, startedAt: now }; -} - -export function pauseClock(clock: TimerClock, now: number): TimerClock { - if (clock.startedAt === null) return clock; - return { ...clock, startedAt: null, elapsedBeforeMs: elapsedMs(clock, now) }; -} - -export type TimerSignal = 'beep' | 'double-beep'; + format: CustomFormat; +} + +/** DebateOwl's own school format opens first, as the app's timer always has. */ +export const DEFAULT_FORMAT: BuiltInFormatId = 'school'; +export const MAX_SAVED_FORMATS = 20; + +// The website's in-app timer translates these format names; the free timer's other names are proper names. +const FORMAT_NAME_KEYS: Partial> = { + school: 'timer.formatStandard', + pf: 'timer.formatPF', + ld: 'timer.formatLD', + policy: 'timer.formatPolicy', +}; + +export function formatNameKey(id: BuiltInFormatId): string | undefined { + return FORMAT_NAME_KEYS[id]; +} + +export type Translate = (key: string, options?: Record) => string; + +/** A format's name: the in-app timer's translated names where the website has them, else its own. */ +export function formatName(choice: FormatChoice, format: RoundFormat, t: Translate): string { + if (choice.id === 'custom') return choice.custom.name; + const key = formatNameKey(choice.id); + return key ? t(key) : format.name; +} + +/** A side's name: the format's own (Government, Team A), translated for custom formats. */ +export function sideNameFor(choice: FormatChoice, format: RoundFormat, side: RoundSegment['side'], t: Translate): string { + if (side === 'both') return t('timer.bothSides', { ns: 'tools' }); + if (choice.id === 'custom') return t(side === 'a' ? 'custom.sideA' : 'custom.sideB', { ns: 'tools' }); + return format.sideNames[side === 'a' ? 0 : 1]; +} + +export function formatFor(choice: FormatChoice): RoundFormat { + return choice.id === 'custom' ? customToRoundFormat(choice.custom) : FORMATS[choice.id]; +} + +export function initialSession(choice: FormatChoice = { id: DEFAULT_FORMAT }, muted = false): TimerSession { + return { choice, round: initialRoundState(formatFor(choice)), muted }; +} + +/** What is kept on the device: the round's timestamps, so a running speech keeps running while the timer is closed. */ +export function serializeSession(session: TimerSession): unknown { + const { choice } = session; + return { + v: 1, + format: choice.id, + ...(choice.id === 'custom' ? { custom: choice.custom, ...(choice.savedId ? { savedId: choice.savedId } : {}) } : {}), + round: session.round, + muted: session.muted, + }; +} + +/** A stored session read back, or null when it does not fit (an old version, a format that changed). */ +export function parseSession(value: unknown): TimerSession | null { + if (typeof value !== 'object' || value === null) return null; + const { v, format, custom, savedId, round, muted } = value as Record; + if (v !== 1) return null; + let choice: FormatChoice | null = null; + if (format === 'custom') { + const parsed = parseCustomFormat(custom); + if (parsed) choice = { id: 'custom', custom: parsed, ...(typeof savedId === 'string' ? { savedId } : {}) }; + } else if (typeof format === 'string' && isBuiltInFormatId(format)) { + choice = { id: format }; + } + if (!choice) return null; + const parsedRound = parseRoundState(round, formatFor(choice)); + return parsedRound ? { choice, round: parsedRound, muted: muted === true } : null; +} + +/** Saved custom formats read back; anything malformed is dropped, the rest kept. */ +export function parseSavedFormats(value: unknown): SavedFormat[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const saved: SavedFormat[] = []; + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) continue; + const { id, format } = entry as Record; + const parsed = parseCustomFormat(format); + if (typeof id !== 'string' || !id || seen.has(id) || !parsed) continue; + seen.add(id); + saved.push({ id, format: parsed }); + if (saved.length === MAX_SAVED_FORMATS) break; + } + return saved; +} + +/** Adds or replaces a saved format, newest first, keeping at most MAX_SAVED_FORMATS. */ +export function upsertSavedFormat(list: readonly SavedFormat[], entry: SavedFormat): SavedFormat[] { + return [entry, ...list.filter((item) => item.id !== entry.id)].slice(0, MAX_SAVED_FORMATS); +} + +/** True when anything is on the clock or a speech has time on it, so closing the timer would leave something behind. */ +export function isActive(round: RoundState): boolean { + return isSpeechRunning(round) || isPrepRunning(round, 'a') || isPrepRunning(round, 'b'); +} + +export function formatClock(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} + +/** Text the lock screen shows, in the app's language: the layouts cannot reach i18n. */ +export interface TimerActivityLabels { + speaking: string; + paused: string; + overtime: string; + prep: string; + idleTitle: string; + idleLine: string; +} /** - * The signal due when the time left moves from `previous` to `current` - * seconds: one beep on reaching the last minute, two beeps at time up. After - * a gap in the background that crossed both, only time up sounds. + * What a Live Activity and the lock-screen widget show: one clock, as times, + * so iOS can draw it counting without the app. A speech's clock is its limit + * counting down from `startedAt` to `endsAt` (pinned at `pausedAt` when + * paused); after `endsAt` it reads as overtime. Prep counts its bank down. */ -export function signalBetween(previous: number, current: number): TimerSignal | null { - if (previous > 0 && current === 0) return 'double-beep'; - if (previous > 60 && current <= 60 && current > 0) return 'beep'; - return null; +export interface TimerActivityState { + kind: 'speech' | 'prep'; + /** "PM", "1AC", or the side whose prep runs. */ + title: string; + /** The speech's full name and side, or "Prep". */ + line: string; + /** When this clock would have started had it run without stopping, in epoch ms. */ + startedAt: number; + endsAt: number; + /** Set while paused: the moment the clock stands at. */ + pausedAt: number | null; + /** Side colour: `a`, `b` or `both`. */ + side: RoundSegment['side']; + labels: TimerActivityLabels; } /** - * The signal due between two readings of the time left, as the screen redraws. - * Time left only falls while the clock runs, so every fall is checked, also one - * that ends in a pause: pausing as 1:01 turns to 1:00 still beeps. A fresh - * clock (a reset or another speech) moves the reading up and sounds nothing. + * The Live Activity for a session at `now`, or null when no clock has time on + * it (a fresh speech with no prep running), so the activity ends. A running + * prep bank wins over a paused speech, because that is what is on the clock. */ -export function signalFor(previous: number, current: number, clock: TimerClock): TimerSignal | null { - if (clock.startedAt === null && clock.elapsedBeforeMs === 0) return null; - return signalBetween(previous, current); +export function timerActivityState( + session: TimerSession, + now: number, + text: { sideName: (side: RoundSegment['side']) => string; prepLine: (side: PrepSide) => string; labels: TimerActivityLabels }, +): TimerActivityState | null { + const format = formatFor(session.choice); + const { round } = session; + for (const side of ['a', 'b'] as PrepSide[]) { + if (!isPrepRunning(round, side)) continue; + const left = prepRemainingMs(round, side, now); + const total = format.prepSeconds * 1000; + return { + kind: 'prep', + title: text.labels.prep, + line: text.prepLine(side), + startedAt: now - (total - left), + endsAt: now + left, + pausedAt: null, + side, + labels: text.labels, + }; + } + const segment = format.segments[round.segmentIndex] ?? format.segments[0]; + const elapsed = elapsedMs(round.speech, now); + const running = isSpeechRunning(round); + if (!running && elapsed === 0) return null; + const startedAt = now - elapsed; + return { + kind: 'speech', + title: segment.short, + line: `${segment.label} · ${text.sideName(segment.side)}`, + startedAt, + endsAt: startedAt + segment.seconds * 1000, + pausedAt: running ? null : now, + side: segment.side, + labels: text.labels, + }; } -export function formatClock(seconds: number): string { - return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +/** The speech's status for the screen: time left (negative in overtime), POI window and grace. */ +export function speechStatus(session: TimerSession, now: number) { + const format = formatFor(session.choice); + const segment = format.segments[session.round.segmentIndex] ?? format.segments[0]; + const elapsed = elapsedMs(session.round.speech, now); + return { format, segment, elapsed, ...segmentStatus(segment, elapsed, format.graceSeconds) }; } diff --git a/apps/mobile/src/widgets/timer-widgets.tsx b/apps/mobile/src/widgets/timer-widgets.tsx new file mode 100644 index 000000000..8ecba2515 --- /dev/null +++ b/apps/mobile/src/widgets/timer-widgets.tsx @@ -0,0 +1,144 @@ +import { HStack, Image, Spacer, Text, VStack } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, lineLimit, monospacedDigit, padding } from '@expo/ui/swift-ui/modifiers'; +import { createLiveActivity, createWidget, type LiveActivityEnvironment, type WidgetEnvironment } from 'expo-widgets'; + +import type { TimerActivityState } from '@/lib/timer'; + +// The timer on the Lock Screen and in the Dynamic Island, drawn by iOS from +// the props alone: the clocks are SwiftUI timer texts, so they count without +// the app running. Each layout runs in the widget extension's own runtime +// ('widget' directive), so it cannot use anything outside its function: every +// string comes in the props, in the app's language, and the colours are the +// app's plume blue (side A), coral (side B) and ink (both sides). +// +// Only live-activity.ts loads this file, and only in a build that has the +// widget extension (nativeCapabilities().liveActivity). Expo Go never does. + +/** What the lock-screen widget shows: the running clock, or a quiet line when none runs. */ +export interface TimerWidgetProps { + state: TimerActivityState | null; + idleTitle: string; + idleLine: string; +} + +const DebateTimerActivity = (props: TimerActivityState, environment: LiveActivityEnvironment) => { + 'widget'; + const HOUR = 3_600_000; + const accent = props.side === 'a' ? '#4CA6FD' : props.side === 'b' ? '#FF8A8E' : '#A9B7C9'; + const at = props.pausedAt ?? Date.now(); + // A speech past its limit counts on as overtime; iOS marks the activity stale at the limit. + const over = props.kind === 'speech' && (environment.isStale === true || at >= props.endsAt); + const pauseTime = props.pausedAt === null ? undefined : new Date(props.pausedAt); + const status = over ? props.labels.overtime : props.pausedAt !== null ? props.labels.paused : props.kind === 'prep' ? props.labels.prep : props.labels.speaking; + const clock = (size: number) => + over ? ( + + ) : ( + + ); + const icon = props.kind === 'prep' ? 'hourglass' : props.pausedAt !== null ? 'pause.fill' : 'timer'; + return { + banner: ( + + + + + {props.title} + + {props.line} + {status} + + + {clock(40)} + + ), + compactLeading: , + compactTrailing: clock(15), + minimal: , + expandedLeading: ( + + {props.title} + {status} + + ), + expandedTrailing: {clock(30)}, + expandedBottom: ( + {props.line} + ), + }; +}; + +const DebateTimerWidget = (props: TimerWidgetProps, environment: WidgetEnvironment) => { + 'widget'; + const state = props.state; + if (!state) { + if (environment.widgetFamily === 'accessoryInline') return {props.idleTitle}; + if (environment.widgetFamily === 'accessoryCircular') return ; + return ( + + + + {props.idleTitle} + + {props.idleLine} + + ); + } + const HOUR = 3_600_000; + // The app adds a timeline entry at the limit, so the widget turns to overtime on time. + const at = state.pausedAt ?? environment.date.getTime(); + const over = state.kind === 'speech' && at >= state.endsAt; + const pauseTime = state.pausedAt === null ? undefined : new Date(state.pausedAt); + const status = over ? state.labels.overtime : state.pausedAt !== null ? state.labels.paused : state.kind === 'prep' ? state.labels.prep : state.labels.speaking; + const clock = (size: number) => + over ? ( + + ) : ( + + ); + if (environment.widgetFamily === 'accessoryInline') { + return ( + + {`${state.title} `} + {clock(14)} + + ); + } + if (environment.widgetFamily === 'accessoryCircular') { + return ( + + {state.title} + {clock(15)} + + ); + } + return ( + + {`${state.title} · ${status}`} + {clock(26)} + + ); +}; + +export const timerActivity = createLiveActivity('DebateTimerActivity', DebateTimerActivity); +export const timerWidget = createWidget('DebateTimerWidget', DebateTimerWidget); diff --git a/package-lock.json b/package-lock.json index b2feb56a1..084ea00cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "@expo-google-fonts/fredoka": "^0.4.1", "@expo-google-fonts/nunito": "^0.4.2", "@expo/dom-webview": "~57.0.1", + "@expo/ui": "~57.0.23", "@formatjs/intl-listformat": "^8.3.15", "@formatjs/intl-pluralrules": "^6.3.15", "@react-native-async-storage/async-storage": "2.2.0", @@ -150,6 +151,7 @@ "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", "expo-sharing": "~57.0.22", + "expo-speech": "~57.0.3", "expo-speech-recognition": "^57.1.1", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", @@ -294,9 +296,9 @@ } }, "apps/mobile/node_modules/@expo/ui": { - "version": "57.0.22", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.22.tgz", - "integrity": "sha512-v0p397fI7XhYBt+O5Q4MCWKB1lwR8cA2xjoQbc/V6W+EmP9qU1sHh0eQ7QHdeE/lyNjWW4wIut0/H/8OjcHo3w==", + "version": "57.0.26", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.26.tgz", + "integrity": "sha512-sEvRjKK08nCq4sXo5PjDckA9OBpxFokCXs5MakWlI1wT1gQNvyliWn/Dwic8XC6YPTN/TbnEp2X/SEduD9+I3Q==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -944,6 +946,15 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-speech": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-speech/-/expo-speech-57.0.3.tgz", + "integrity": "sha512-rcO8izKziksrV4X3qQ5g+prFNTZmLYDEGEVnTvuvpOZDwjzP9nsdoaFymhY+qj9AJfMcpvR7Wshm+zpislyJBg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "apps/mobile/node_modules/expo-speech-recognition": { "version": "57.1.1", "resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-57.1.1.tgz", @@ -1050,35 +1061,6 @@ "react-native": "*" } }, - "apps/mobile/node_modules/expo-widgets/node_modules/@expo/ui": { - "version": "57.0.23", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.23.tgz", - "integrity": "sha512-5QW1uzs8cGBlBKQvzNLDmmAeTCiJn4HVCYC2tddyOtXnXsUKGY7Rvgw6otr9Q2rPcOY5mC34wJIspViamvptww==", - "license": "MIT", - "dependencies": { - "sf-symbols-typescript": "^2.1.0", - "vaul": "^1.1.2" - }, - "peerDependencies": { - "@babel/core": "*", - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*", - "react-native-worklets": "*" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native-worklets": { - "optional": true - } - } - }, "apps/mobile/node_modules/expo/node_modules/@expo/cli": { "version": "57.0.28", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.28.tgz", diff --git a/packages/core/src/free-tools/custom-format.test.ts b/packages/core/src/free-tools/custom-format.test.ts new file mode 100644 index 000000000..a13db3144 --- /dev/null +++ b/packages/core/src/free-tools/custom-format.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { CUSTOM_LIMITS, parseCustomFormat } from './custom-format'; + +const speech = { label: 'Opening', seconds: 300, kind: 'speech', side: 'a', poi: true }; + +describe('parseCustomFormat', () => { + it('cleans text and drops a POI flag on questions', () => { + const parsed = parseCustomFormat({ + name: ' Club\u0007 night ', + prepSeconds: 60, + segments: [speech, { ...speech, label: 'Questions', kind: 'cross-examination', side: 'both' }], + }); + expect(parsed?.name).toBe('Club night'); + expect(parsed?.segments[1]).toEqual({ label: 'Questions', seconds: 300, kind: 'cross-examination', side: 'both', poi: false }); + }); + + it('refuses a format outside the limits a shared link is held to', () => { + expect(parseCustomFormat({ name: 'x', prepSeconds: 0, segments: [] })).toBeNull(); + expect(parseCustomFormat({ name: 'x', prepSeconds: 0, segments: [{ ...speech, seconds: 5 }] })).toBeNull(); + expect(parseCustomFormat({ name: 'x', prepSeconds: CUSTOM_LIMITS.maxPrepSeconds + 1, segments: [speech] })).toBeNull(); + expect(parseCustomFormat({ name: 'x', prepSeconds: 0, segments: [{ ...speech, label: ' ' }] })).toBeNull(); + expect(parseCustomFormat({ name: 'x', prepSeconds: 0, segments: Array(CUSTOM_LIMITS.segments + 1).fill(speech) })).toBeNull(); + }); +}); diff --git a/src/locales/en.json b/src/locales/en.json index 10a8951c0..85f065290 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2860,7 +2860,16 @@ "speechOf": "Speech {{n}} of {{of}}", "ofTotal": "of {{total}}", "next": "Next speech", - "reset": "Reset" + "reset": "Reset", + "widgetTitle": "DebateOwl timer", + "widgetIdle": "No speech is on the clock.", + "poiSignal": "Points of information open or close", + "knock": "A knock", + "keepsRunning": "The clock keeps running when you close the timer.", + "editFormat": "Edit this format", + "deleteFormat": "Delete this format", + "deleteFormatTitle": "Delete “{{name}}”?", + "deleteFormatMessage": "It is removed from this device. The round on the timer keeps its speeches." }, "crossfire": { "voiceLater": "Coming with the app build.", diff --git a/src/locales/es.json b/src/locales/es.json index d2b12546a..b332b5e51 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2860,7 +2860,16 @@ "speechOf": "Discurso {{n}} de {{of}}", "ofTotal": "de {{total}}", "next": "Siguiente discurso", - "reset": "Reiniciar" + "reset": "Reiniciar", + "widgetTitle": "Cronómetro de DebateOwl", + "widgetIdle": "No hay ningún discurso en marcha.", + "poiSignal": "Se abren o se cierran los POI", + "knock": "Un golpe", + "keepsRunning": "El reloj sigue en marcha aunque cierres el cronómetro.", + "editFormat": "Editar este formato", + "deleteFormat": "Eliminar este formato", + "deleteFormatTitle": "¿Eliminar «{{name}}»?", + "deleteFormatMessage": "Se elimina de este dispositivo. La ronda del cronómetro conserva sus discursos." }, "crossfire": { "voiceLater": "Llegará con la versión de la app.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 109356bb1..3c2a24214 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2860,7 +2860,16 @@ "speechOf": "{{n}}. beszéd / {{of}}", "ofTotal": "összesen {{total}}", "next": "Következő beszéd", - "reset": "Visszaállítás" + "reset": "Visszaállítás", + "widgetTitle": "DebateOwl időmérő", + "widgetIdle": "Most nem fut beszéd.", + "poiSignal": "Megnyílik vagy lezárul a POI-idő", + "knock": "Kopogás", + "keepsRunning": "Ha bezárod az időmérőt, az óra tovább fut.", + "editFormat": "Formátum szerkesztése", + "deleteFormat": "Formátum törlése", + "deleteFormatTitle": "Törlöd ezt: „{{name}}”?", + "deleteFormatMessage": "Törlődik erről az eszközről. Az időmérőn lévő forduló megtartja a beszédeit." }, "crossfire": { "voiceLater": "Az alkalmazás következő verziójával érkezik.", From aa9cacf85c23ed33670f975ebbfb42f9ea486325 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:15:51 +0000 Subject: [PATCH 03/11] Bring voice rounds and dictation to the app Sparring and Crossfire get the website's voice rounds: the student speaks each turn through Apple's speech recognition (expo-speech-recognition, restarting when iOS ends a request, as the website's capture does), the words fill the draft, and the turn's speaking time, pace, fillers and long pauses are measured with the shared delivery code. The opponent's replies play from the voice function's signed ElevenLabs segments with expo-audio, falling back to the iOS voice. The first-use privacy notice keeps the website's wording, with Apple in place of the browser. Generate's motion and Ask's question take dictation. Voice needs the development build; in Expo Go the switch stays off with the existing note and the dictation buttons do not show. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 143 ++++++- apps/mobile/src/app/ask.tsx | 23 +- apps/mobile/src/app/crossfire.tsx | 98 +++-- apps/mobile/src/app/generate.tsx | 45 ++- apps/mobile/src/app/sparring/index.tsx | 129 ++++-- apps/mobile/src/components/sparring-views.tsx | 25 +- apps/mobile/src/components/voice.tsx | 371 ++++++++++++++++++ apps/mobile/src/lib/crossfire.ts | 14 +- apps/mobile/src/lib/opponent-voice.ts | 246 ++++++++++++ apps/mobile/src/lib/opponent-voice.web.ts | 58 +++ apps/mobile/src/lib/sparring.ts | 21 +- apps/mobile/src/lib/speech-capture.test.ts | 106 +++++ apps/mobile/src/lib/speech-capture.ts | 275 +++++++++++++ apps/mobile/src/lib/speech-recognition.ts | 36 ++ apps/mobile/src/lib/speech-recognition.web.ts | 58 +++ apps/mobile/src/lib/use-speech-capture.ts | 56 +++ apps/mobile/src/lib/voice-availability.ts | 90 +++++ src/locales/en.json | 9 + src/locales/es.json | 9 + src/locales/hu.json | 9 + 20 files changed, 1733 insertions(+), 88 deletions(-) create mode 100644 apps/mobile/src/components/voice.tsx create mode 100644 apps/mobile/src/lib/opponent-voice.ts create mode 100644 apps/mobile/src/lib/opponent-voice.web.ts create mode 100644 apps/mobile/src/lib/speech-capture.test.ts create mode 100644 apps/mobile/src/lib/speech-capture.ts create mode 100644 apps/mobile/src/lib/speech-recognition.ts create mode 100644 apps/mobile/src/lib/speech-recognition.web.ts create mode 100644 apps/mobile/src/lib/use-speech-capture.ts create mode 100644 apps/mobile/src/lib/voice-availability.ts diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 0da814194..a9442aa83 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -13,7 +13,7 @@ * SCREENS=you,timer limits the signed-in screens to those groups: debate, * debate-check, library, prepare, practice, you, empty, ask, results, brainstorm, * factoids, case-edits, context, limits, - * timer, crossfire, sparring, flowchart, one-off, history, announcements, + * timer, voice, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * @@ -1357,7 +1357,7 @@ async function fakeCrossfire(page: Page, language: keyof typeof CROSSFIRE_TEXT) if (input.operation === 'next-turn') { const type = input.expectedReply ?? 'answer'; output = { type, text: type === 'answer' ? text.answers[exchange] : text.questions[exchange] }; - extra = { signalToken: 'screenshot-signal' }; + extra = { signalToken: 'screenshot-signal', ...((body as { voice?: boolean }).voice ? { voice: { segments: [{ text: 'Screenshot voice segment.', token: 'screenshot-voice-token' }] } } : {}) }; } else if (input.operation === 'signal') { output = { signal: { answerTurnId: `T${turns.length + 1}`, answerQuality: exchange === 0 ? 'direct' : 'partial', answerQualityConfidence: 0.82, concession: exchange === 0 ? 'none' : 'implicit', concessionConfidence: 0.74, followUpOpportunity: 0.81 } }; } else { @@ -1490,7 +1490,9 @@ async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) { }, }; } - return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output } }); + // A voice round's turn carries a signed segment for the opponent's voice; the web preview only records it. + const voice = action === 'respond' && (body as { voice?: boolean }).voice ? { voice: { segments: [{ text: 'Screenshot voice segment.', token: 'screenshot-voice-token' }] } } : {}; + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output, ...voice } }); }); await page.route(`${REST}/rpc/attach_to_debate`, (route) => route.fulfill({ status: 204, body: '' })); return { @@ -1567,6 +1569,137 @@ async function shootSparringScreens(context: BrowserContext, prefix: string, lan await page.close(); } +// What the web preview's fake recognizer "hears" (speech-recognition.web.ts): a filler, then a pause long enough to count. +const SPOKEN = { + en: [ + { text: 'Um, a ban punishes the people with the fewest choices.', afterMs: 1_200 }, + { text: 'The nurse who drives in at five has no bus at that hour,', afterMs: 3_600 }, + { text: 'and permits only help those who know how to apply.', afterMs: 1_400 }, + ], + hu: [ + { text: 'Hát, a tilalom azokat bünteti, akiknek a legkevesebb választásuk van.', afterMs: 1_200 }, + { text: 'A hajnali ötkor munkába autózó ápolónak abban az órában nincs busza,', afterMs: 3_600 }, + { text: 'az engedély pedig csak annak segít, aki tudja, hogyan kérje.', afterMs: 1_400 }, + ], +} as const; + +/** + * Voice rounds and dictation, on the web preview's fake recognizer and + * opponent voice: the first-use notice, a spoken Sparring speech while it is + * heard, the measured delivery on the scorecard, a spoken Crossfire question + * and the round's delivery in the debrief, and Generate's dictation. The run + * checks that the turns asked the server for the opponent's voice and that + * the opponent's replies went to it. + */ +async function shootVoiceScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const lang = language === 'hu' ? 'hu' : 'en'; + const text = SPARRING_TEXT[lang]; + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} voice`); + await pinSettings(page, language); + await page.addInitScript((phrases) => { + (window as unknown as { __debatelabFakeSpeech: unknown }).__debatelabFakeSpeech = { phrases }; + localStorage.removeItem('debatelab.voice-notice-v1'); + }, [...SPOKEN[lang]]); + const voiceRequests: boolean[] = []; + page.on('request', (request) => { + if (!request.url().endsWith('/functions/v1/ai') || request.method() !== 'POST') return; + const body = request.postDataJSON() as { input?: { action?: string; operation?: string }; quotaCheck?: boolean } | null; + if (!body?.quotaCheck && (body?.input?.action === 'respond' || body?.input?.operation === 'next-turn')) voiceRequests.push((body as { voice?: boolean }).voice === true); + }); + const fake = await fakeSparring(page, lang); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-sparring').click({ timeout: 30_000 }); + await page.getByTestId('sparring-setup').waitFor({ timeout: 15_000 }); + 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 page.getByTestId('voice-mode').getByRole('switch').click(); + await page.getByTestId('voice-notice').waitFor(); + await page.getByTestId('voice-notice').scrollIntoViewIfNeeded(); + await settle(page); + await shoot(page, `${prefix}-voice-notice`); + await page.getByTestId('voice-notice-accept').click(); + + 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('voice-mic').waitFor({ timeout: 15_000 }); + await page.getByTestId('voice-mic').click(); + // Listening: the words fill the draft as they are heard. + await page.waitForFunction((last) => (document.querySelector('[data-testid="sparring-input"]') as HTMLTextAreaElement | null)?.value.includes(last), SPOKEN[lang][1].text); + await page.waitForTimeout(300); + await shoot(page, `${prefix}-voice-speaking`); + await page.waitForFunction((last) => (document.querySelector('[data-testid="sparring-input"]') as HTMLTextAreaElement | null)?.value.includes(last), SPOKEN[lang][2].text); + await page.getByTestId('sparring-main').click(); + await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); + await page.getByTestId('sparring-input').fill(text.speeches[1]); + await page.getByTestId('sparring-main').click(); + await page.getByTestId('spoken-delivery').waitFor({ timeout: 15_000 }); + await page.getByTestId('spoken-delivery').scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-voice-scorecard`); + const facts = (await page.getByTestId('delivery-facts').first().textContent()) ?? ''; + if (!/\d/.test(facts) || !facts.includes(lang === 'hu' ? 'hát' : 'um')) problems.push(`${prefix} voice: the delivery facts read "${facts}"`); + const spoken = await page.evaluate(() => (window as unknown as { __debatelabOpponentVoice?: { segments: number }[] }).__debatelabOpponentVoice ?? []); + if (spoken.length < 2 || spoken.some((item) => item.segments !== 1)) problems.push(`${prefix} voice: the opponent voice got ${JSON.stringify(spoken)}`); + await page.close(); + + // Crossfire: a spoken question, then the round's delivery in the debrief. + const crossfire = await context.newPage(); + watchErrors(crossfire, problems, `${prefix} voice crossfire`); + await pinSettings(crossfire, language); + await crossfire.addInitScript((phrases) => { + (window as unknown as { __debatelabFakeSpeech: unknown }).__debatelabFakeSpeech = { phrases }; + }, [{ text: CROSSFIRE_TEXT[lang].questions[0], afterMs: 1_000 }]); + crossfire.on('request', (request) => { + if (!request.url().endsWith('/functions/v1/ai') || request.method() !== 'POST') return; + const body = request.postDataJSON() as { input?: { operation?: string }; quotaCheck?: boolean } | null; + if (!body?.quotaCheck && body?.input?.operation === 'next-turn') voiceRequests.push((body as { voice?: boolean }).voice === true); + }); + await fakeCrossfire(crossfire, lang); + await crossfire.goto(`${ORIGIN}/practice`); + await crossfire.getByTestId('practice-tool-crossfire').click({ timeout: 30_000 }); + await crossfire.getByTestId('crossfire-setup').waitFor({ timeout: 15_000 }); + await crossfire.waitForFunction(() => (document.querySelector('[data-testid="crossfire-resolution"]') as HTMLTextAreaElement | null)?.value.trim()); + await crossfire.getByTestId('crossfire-argument').fill(CROSSFIRE_TEXT[lang].argument); + // The notice was accepted in the Sparring round, so the switch turns on at once. + await crossfire.getByTestId('voice-mode').getByRole('switch').click(); + await crossfire.getByRole('button', { name: /^(Start crossfire|Keresztkérdezés indítása|Iniciar interrogatorio)$/ }).click(); + await crossfire.getByTestId('voice-mic').click({ timeout: 15_000 }); + await crossfire.waitForFunction((question) => (document.querySelector('[data-testid="crossfire-input"]') as HTMLTextAreaElement | null)?.value.includes(question), CROSSFIRE_TEXT[lang].questions[0]); + await crossfire.waitForTimeout(300); + await shoot(crossfire, `${prefix}-voice-crossfire`); + await crossfire.getByTestId('crossfire-send').click(); + await crossfire.getByTestId('crossfire-turn').nth(1).waitFor({ timeout: 15_000 }); + // A typed second question: the fixed debrief cites the second exchange. + await crossfire.getByTestId('crossfire-input').fill(CROSSFIRE_TEXT[lang].questions[1]); + await crossfire.getByTestId('crossfire-send').click(); + await crossfire.getByTestId('crossfire-turn').nth(3).waitFor({ timeout: 15_000 }); + await crossfire.getByTestId('crossfire-end').click(); + await crossfire.getByTestId('spoken-delivery').waitFor({ timeout: 15_000 }); + await crossfire.getByTestId('spoken-delivery').scrollIntoViewIfNeeded(); + await crossfire.waitForTimeout(400); + await shoot(crossfire, `${prefix}-voice-crossfire-debrief`); + await crossfire.close(); + if (voiceRequests.length < 4 || voiceRequests.some((asked) => !asked)) problems.push(`${prefix} voice: turns asked for the voice as ${JSON.stringify(voiceRequests)}`); + + // Generate: dictating the motion. + const generate = await context.newPage(); + watchErrors(generate, problems, `${prefix} dictation`); + await pinSettings(generate, language); + await generate.addInitScript((phrases) => { + (window as unknown as { __debatelabFakeSpeech: unknown }).__debatelabFakeSpeech = { phrases }; + }, [{ text: lang === 'hu' ? 'Tiltsuk ki az autókat a belvárosból' : 'This House would ban private cars from city centres', afterMs: 800 }]); + await generate.goto(`${ORIGIN}/generate`); + await generate.getByTestId('generate-motion').waitFor({ timeout: 30_000 }); + await generate.getByTestId('generate-motion').fill(''); + await generate.getByTestId('dictate').click(); + await generate.waitForFunction(() => ((document.querySelector('[data-testid="generate-motion"]') as HTMLTextAreaElement | null)?.value ?? '').length > 10); + await generate.waitForTimeout(300); + await shoot(generate, `${prefix}-voice-dictation`); + await generate.close(); +} + /** * The Timer from the Practice tab, on the website's free timer: idle, * running, and over time on the school format; a British Parliamentary @@ -2264,6 +2397,10 @@ async function main() { await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits')); shots += 2; } + if (want('voice')) { + await shootVoiceScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'voice')); + shots += 6; + } if (want('timer')) { await shootTimerScreens(signedIn, prefix, language, problems); shots += 6; diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index e9d67e5ed..d447728cf 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -19,6 +19,7 @@ import { OneOffNotice } from '@/components/one-off-notice'; import { ContextLine } from '@/components/context-line'; import { DailyUsageLine } from '@/components/tool-form'; import { Text } from '@/components/text'; +import { appendDictation, DictationButton } from '@/components/voice'; import { AIError, isAbortError, type AIErrorKind, type ResponseExtras } from '@/lib/ai-request'; import { askOnce, canStream, isFinalAskError, isMessageWithinBudget, streamAsk, type AskMode, type AskOptions } from '@/lib/ask'; import { pickAskFiles, readAskFiles, type AskAttachment } from '@/lib/ask-attachments'; @@ -34,6 +35,7 @@ import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { addSource, fetchSources, type Source } from '@/lib/sources'; import { useCopyHolder } from '@/lib/use-copy-holder'; +import { useSpeechCapture } from '@/lib/use-speech-capture'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -79,7 +81,7 @@ export default function AskScreen() { const { session } = useAuth(); const userId = session?.user.id ?? null; const { current, oneOff, activeDebateId, refresh } = useDebates(); - const { outputLanguage, readFeatureConfig } = useSettings(); + const { language, outputLanguage, readFeatureConfig } = useSettings(); const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(null); @@ -89,7 +91,15 @@ export default function AskScreen() { const [options, setOptions] = useState(NO_OPTIONS); const [mode, setMode] = useState('general'); const [attachments, setAttachments] = useState([]); - const [input, setInput] = useState(''); + const [input, setInputState] = useState(''); + // Read when dictated words land, before React has drawn the last ones. + const inputRef = useRef(''); + const setInput = useCallback((value: string) => { + inputRef.current = value; + setInputState(value); + }, []); + // Dictation fills the question, as the website's microphone beside it does. + const dictation = useSpeechCapture(language, (text) => setInput(appendDictation(inputRef.current, text))); const [error, setError] = useState<{ kind: AIErrorKind; question: string; files?: AskAttachment[] } | null>(null); const [notice, setNotice] = useState(''); const [historyOpen, setHistoryOpen] = useState(false); @@ -598,10 +608,11 @@ export default function AskScreen() { testID="ask-attach"> + void send(input, messages, attachments)} + onPress={() => { + // Words still on their way would land in the next question. + dictation.cancel(); + void send(input, messages, attachments); + }} style={[styles.send, { backgroundColor: colors.primaryFill }, (busy || !input.trim()) && styles.sendDisabled]} testID="ask-send"> {busy ? : } diff --git a/apps/mobile/src/app/crossfire.tsx b/apps/mobile/src/app/crossfire.tsx index e4728e22d..c7fb77efd 100644 --- a/apps/mobile/src/app/crossfire.tsx +++ b/apps/mobile/src/app/crossfire.tsx @@ -8,6 +8,7 @@ import { type CrossfireState, type CrossfireTurn, } from '@debatelab/core/crossfire/schemas'; +import { combineDelivery, measureDelivery } from '@debatelab/core/voice/delivery'; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,9 +29,13 @@ import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { TimerRing } from '@/components/timer-ring'; import { ToolTitle, WorkingIn } from '@/components/tool-form'; +import { appendDictation, DeliverySummary, OpponentVoiceBar, VoiceMicControl, VoiceModeSwitch } from '@/components/voice'; import { abortError, AIError, isAbortError, type ResponseExtras, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { haptics } from '@/lib/haptics'; +import { opponentVoice } from '@/lib/opponent-voice'; +import { useSpeechCapture } from '@/lib/use-speech-capture'; +import { useVoiceAvailability } from '@/lib/voice-availability'; import { backLabel } from '@/lib/back-label'; import { resetContextChoices } from '@/lib/context-plan'; import { buildDebateContext } from '@/lib/context-request'; @@ -100,8 +105,10 @@ function nextTurn(sequence: number, speaker: 'user' | 'ai', kind: 'question' | ' * Crossfire in its typed mode, as the website's CrossfirePage: set up a * resolution and a case, question the AI or be questioned for a few timed * minutes, then read the debrief. The live round is full screen; the round - * stays for the session if the student leaves it. Voice rounds need the - * development build (speech recognition and the opponent's voice). + * stays for the session if the student leaves it. In a voice round the + * student speaks each question or answer (the words fill the box, and the + * delivery is measured) and the opponent replies aloud; it needs speech + * recognition, so it runs in the development build, not in Expo Go. */ export default function CrossfireScreen() { const { t } = useTranslation(); @@ -125,7 +132,31 @@ export default function CrossfireScreen() { const [checkingQuota, setCheckingQuota] = useState(false); // The debate chosen when the round started; null for one-off rounds. const debateTarget = useRef(crossfireDraft.get()?.debateId ?? null); - const [draftTurn, setDraftTurn] = useState(''); + const [draftTurn, setDraftTurnState] = useState(''); + // Read after the microphone's last words land, before React has drawn them. + const draftRef = useRef(''); + const setDraftTurn = useCallback((value: string) => { + draftRef.current = value; + setDraftTurnState(value); + }, []); + const stateRef = useRef(state); + stateRef.current = state; + // A voice round falls back to typing when the microphone is lost. + const voiceAvailability = useVoiceAvailability(); + const voiceActive = Boolean(state.voice) && voiceAvailability.available; + const voiceActiveRef = useRef(voiceActive); + voiceActiveRef.current = voiceActive; + const capture = useSpeechCapture(outputLanguage, (text) => setDraftTurn(appendDictation(draftRef.current, text, MAX_CROSSFIRE_TURN_CHARS))); + // Each round is measured on its own, and a start still waiting for permission does not carry over. + const captureScope = state.phase === 'setup' ? 'setup' : state.sessionId; + const cancelCapture = capture.cancel; + useEffect(() => cancelCapture, [cancelCapture, captureScope]); + // The microphone closes when it is not the student's turn. + const awaitingUserTurn = expectedUserTurnKind(state) !== null; + const stopCapture = capture.stop; + useEffect(() => { + if (!awaitingUserTurn) void stopCapture(); + }, [awaitingUserTurn, stopCapture]); const [now, setNow] = useState(() => Date.now()); const [formError, setFormError] = useState(''); // A case or an attack opened in Crossfire while a round runs is not brought in, as on the website; say so. @@ -155,6 +186,7 @@ export default function CrossfireScreen() { live.current = false; requestRef.current?.abort(); abortSignals(); + opponentVoice.stop(); }; }, [abortSignals]); @@ -232,20 +264,22 @@ export default function CrossfireScreen() { const controller = new AbortController(); requestRef.current = controller; const debateId = debateTarget.current; + const spoken = voiceActiveRef.current; try { const { config, reply, ...extras } = await withTurnDeadline(controller.signal, async (signal) => { const { config, context } = await requestContext(forUser, debateId, signal); if (signal.aborted) throw abortError(); const turn = await crossfireTurn( { operation: 'next-turn', config: snapshot.config, transcript: snapshot.turns, expectedReply }, - { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal, quotaSession: snapshot.sessionId }, + { config, language: outputLanguage, debateContext: crossfireContext(context, snapshot.config), signal, quotaSession: snapshot.sessionId, voice: spoken }, ); return { config, ...turn }; }); if (controller.signal.aborted) return; dispatch({ type: 'ai-turn-received', turn: nextTurn(snapshot.turns.length + 1, 'ai', reply.type, reply.text) }); - // The turn is back with the student. + // The turn is back with the student; in a voice round the opponent says it aloud. haptics.turn(); + if (spoken && live.current) opponentVoice.speak({ feature: 'crossfire', text: reply.text, voice: reply.voice, language: outputLanguage }); afterResponse(forUser, debateId, extras); if (reply.signalToken) { const signalController = new AbortController(); @@ -314,6 +348,8 @@ export default function CrossfireScreen() { const endSession = useCallback((reason: 'manual' | 'timer') => { if (state.phase !== 'active' || !userId) return; requestRef.current?.abort(); + opponentVoice.stop(); + capture.cancel(); if (state.turns.length === 0) { dispatch({ type: 'session-reset', prefill: { resolution: state.config.resolution, argument: state.config.argument, seedQuestions: state.config.seedQuestions } }); setFormError(t('crossfire.debriefEmptyDesc')); @@ -323,7 +359,7 @@ export default function CrossfireScreen() { const next = crossfireReducer(state, action); dispatch(action); if (next.phase === 'debriefing') void runDebrief(next, userId); - }, [runDebrief, state, t, userId]); + }, [capture, runDebrief, state, t, userId]); useEffect(() => { if (state.phase === 'active' && now >= state.endsAt) endSession('timer'); @@ -333,6 +369,7 @@ export default function CrossfireScreen() { if (state.phase !== 'setup') return; debateTarget.current = debateId; setNotice(''); + if (state.voice) opponentVoice.beginRound(); const action = { type: 'session-started' as const, sessionId: `crossfire-${Date.now()}`, now: Date.now() }; const next = crossfireReducer(state, action); dispatch(action); @@ -390,20 +427,34 @@ export default function CrossfireScreen() { if (live.current) beginSession(debateId, forUser); }; - const submitTurn = () => { + const submitTurn = async () => { if (state.phase !== 'active' || !userId) return; - const kind = expectedUserTurnKind(state); - const text = draftTurn.trim(); + // Stopping also withdraws a start still waiting for permission; the last words land in the box first. + if (capture.listening) await capture.stop(); + else void capture.stop(); + const latest = stateRef.current; + if (!live.current || latest.phase !== 'active') return; + const kind = expectedUserTurnKind(latest); + const text = draftRef.current.trim(); if (!kind || !text || text.length > MAX_CROSSFIRE_TURN_CHARS) return; const expectedReply = kind === 'question' ? 'answer' : 'question'; - const action = { type: 'user-turn-submitted' as const, turn: nextTurn(state.turns.length + 1, 'user', kind, text), expectedReply } as const; - const next = crossfireReducer(state, action); - if (next === state) return; + const delivery = voiceActiveRef.current ? measureDelivery(capture.getTake(), outputLanguage) : null; + capture.resetTake(); + const turn = { ...nextTurn(latest.turns.length + 1, 'user', kind, text), ...(delivery ? { delivery } : {}) }; + const action = { type: 'user-turn-submitted' as const, turn, expectedReply } as const; + const next = crossfireReducer(latest, action); + if (next === latest) return; dispatch(action); setDraftTurn(''); if (next.phase === 'active') void runNextTurn(next, expectedReply, userId); }; + const startSpeaking = () => { + // Speaking over the opponent ends its audio. + opponentVoice.stop(); + void capture.start(); + }; + const retryTurn = () => { if (state.phase !== 'active' || state.request.status !== 'failed' || !userId) return; const expectedReply = state.request.expectedReply; @@ -422,6 +473,8 @@ export default function CrossfireScreen() { const restart = () => { requestRef.current?.abort(); abortSignals(); + opponentVoice.stop(); + capture.cancel(); debateTarget.current = null; dispatch({ type: 'session-reset' }); setDraftTurn(''); @@ -525,15 +578,8 @@ export default function CrossfireScreen() { value={config.useSourceLibrary} onChange={(useSourceLibrary) => dispatch({ type: 'config-patched', patch: { useSourceLibrary } })} /> - {/* Voice rounds need speech recognition and the opponent's voice, which come with the development build. */} - {}} - disabled - /> + dispatch({ type: 'voice-toggled', voice })} description={t('voiceRound.mode.crossfireDescription')} /> {formError ? ( @@ -546,6 +592,8 @@ export default function CrossfireScreen() { } if (state.phase === 'complete') { + // The measured facts of every turn the student spoke, added up, as the website's debrief shows them. + const spokenDelivery = combineDelivery(state.turns.flatMap((turn) => (turn.delivery ? [turn.delivery] : []))); return ( + {spokenDelivery ? : null} @@ -569,7 +618,7 @@ export default function CrossfireScreen() { const remaining = state.phase === 'active' ? Math.max(0, Math.ceil((state.endsAt - now) / 1000)) : 0; const modeLabel = state.config.mode === 'question-ai' ? t('crossfire.modeQuestionAi') : t('crossfire.modeAiQuestionsUser'); const styleKey = `crossfire.style${state.config.style[0].toUpperCase()}${state.config.style.slice(1)}`; - const canSend = Boolean(draftTurn.trim()) && inputKind !== null; + const canSend = (Boolean(draftTurn.trim()) || capture.listening) && inputKind !== null; return ( @@ -618,6 +667,8 @@ export default function CrossfireScreen() { onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}> + {voiceActive ? : null} + {notice ? ( {notice} @@ -688,12 +739,13 @@ export default function CrossfireScreen() { {t('crossfire.charactersRemaining', { count: MAX_CROSSFIRE_TURN_CHARS - draftTurn.length })} + {voiceActive ? void capture.stop()} /> : null} void submitTurn()} style={[styles.send, { backgroundColor: colors.primaryFill }, !canSend && styles.sendDisabled]} testID="crossfire-send"> diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index e84aad068..bfdceb649 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -1,5 +1,6 @@ import { generateDraftSections } from '@debatelab/core/ai/live-sections'; import type { GenerateInput } from '@debatelab/core/ai/schemas/generate'; +import { MAX_MOTION_LENGTH } from '@debatelab/core/validation/motion'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,6 +17,7 @@ import { Text } from '@/components/text'; import { ContextLine } from '@/components/context-line'; import { LiveToolRun, useLiveRun } from '@/components/live-run'; import { ToolError, ToolTitle, WorkingIn } from '@/components/tool-form'; +import { appendDictation, DictationButton, DictationStatus } from '@/components/voice'; import { AIError, isAbortError, type AIErrorKind } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; @@ -29,6 +31,7 @@ import { openRefusal } from '@/lib/refusal'; import { arrivalNotices, autosaveSources, generateCase, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; +import { useSpeechCapture } from '@/lib/use-speech-capture'; import { useTheme } from '@/theme/use-theme'; // The website's defaults (GenerateForm). The website keeps the last form for the session; so does the app. @@ -51,7 +54,7 @@ export default function GenerateScreen() { const { session } = useAuth(); const userId = session?.user.id ?? null; const { debates, current, oneOff, create, switchTo, refresh } = useDebates(); - const { outputLanguage, readFeatureConfig } = useSettings(); + const { language, outputLanguage, readFeatureConfig } = useSettings(); const { colors } = useTheme(); const [form, setFormState] = useState(lastForm.get); const [motionError, setMotionError] = useState(false); @@ -61,6 +64,11 @@ export default function GenerateScreen() { const abort = useRef(null); const live = useRef(true); const liveRun = useLiveRun(); + // Dictation fills the motion, as the website's microphone beside it does. + const dictation = useSpeechCapture(language, (text) => { + setForm({ motion: appendDictation(lastForm.get().motion, text, MAX_MOTION_LENGTH) }); + setMotionError(false); + }); const setForm = useCallback((patch: Partial) => setFormState((previous) => { @@ -224,19 +232,26 @@ export default function GenerateScreen() { /> ) : ( <> - { - setForm({ motion }); - if (motion.trim()) setMotionError(false); - }} - placeholder={t('generateForm.motionPlaceholder')} - multiline - invalid={motionError} - style={styles.motion} - testID="generate-motion" - /> + + + { + setForm({ motion }); + if (motion.trim()) setMotionError(false); + }} + placeholder={t('generateForm.motionPlaceholder')} + multiline + maxLength={MAX_MOTION_LENGTH} + invalid={motionError} + style={styles.motion} + testID="generate-motion" + /> + + + + {motionError ? ( {t('debate.motionRequired')} @@ -312,6 +327,8 @@ export default function GenerateScreen() { } const styles = StyleSheet.create({ + motionRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 10 }, + motionField: { flex: 1 }, motion: { minHeight: 92, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, invalid: { fontSize: 14, marginTop: -6, marginLeft: 4 }, side: { gap: 7 }, diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 3f938b745..03d66aa46 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -14,6 +14,7 @@ import { tickSparringTimer, type SparringSession, } from '@debatelab/core/sparring/session'; +import { measureDelivery } from '@debatelab/core/voice/delivery'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,7 +26,7 @@ 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 { FormGroup, SelectRow } from '@/components/form-rows'; import { Icon } from '@/components/icon'; import { LiveTopBar, MainRoundControl, RoundIconButton, SideControl } from '@/components/round-controls'; import { SegmentedControl } from '@/components/segmented-control'; @@ -35,6 +36,7 @@ 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 { appendDictation, OpponentVoiceBar, VoiceMicControl, VoiceModeSwitch } from '@/components/voice'; import { AIError, isAbortError, untilAborted } from '@/lib/ai-request'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; @@ -44,10 +46,13 @@ import { openRefusal } from '@/lib/refusal'; import { sessionQuotaRefusal, useSessionQuota } from '@/lib/session-quota'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; +import { opponentVoice } from '@/lib/opponent-voice'; import { useSettings } from '@/lib/settings'; import { attachSparring, judgeSparring, newSparringId, noteJudgedRound, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; import { formatClock } from '@/lib/timer'; import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; +import { useSpeechCapture, type SpeechCaptureApi } from '@/lib/use-speech-capture'; +import { useVoiceAvailability } from '@/lib/voice-availability'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -83,7 +88,10 @@ function classify(cause: unknown, judge: boolean): Failure { * 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. + * again. A voice round, as the website's, lets the student speak each speech + * (the words fill the draft, and the speech's delivery is measured) and + * plays the opponent's speeches aloud; it needs speech recognition, so it + * runs in the development build, not in Expo Go. */ export default function SparringScreen() { const { t } = useTranslation(); @@ -105,6 +113,7 @@ export default function SparringScreen() { const [motion, setMotion] = useState(''); const [format, setFormat] = useState('standard'); const [difficulty, setDifficulty] = useState('medium'); + const [voiceMode, setVoiceMode] = useState(false); const [formError, setFormError] = useState(''); const [sideMenu, setSideMenu] = useState(false); const [notice, setNotice] = useState(''); @@ -117,6 +126,11 @@ export default function SparringScreen() { const roundQuota = useSessionQuota('sparring'); // Start waits for the round's cost; a second tap meanwhile does nothing. const [checkingQuota, setCheckingQuota] = useState(false); + // A voice round falls back to typing when the microphone is lost. + const voiceAvailability = useVoiceAvailability(); + const voiceActive = Boolean(session?.voice) && voiceAvailability.available; + const voiceActiveRef = useRef(voiceActive); + voiceActiveRef.current = voiceActive; // Another account starts with no round: the session slot was already cleared, and this screen's copy goes too. useEffect(() => { @@ -141,6 +155,7 @@ export default function SparringScreen() { return () => { live.current = false; requestRef.current?.abort(); + opponentVoice.stop(); }; }, []); @@ -196,9 +211,10 @@ export default function SparringScreen() { // 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( + const spoken = voiceActiveRef.current; + const { speech: generated, voice } = 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 }, + { config, debateId: snapshot.debateId ?? null, signal: controller.signal, voice: spoken }, ); if (controller.signal.aborted || owner.current !== forUser) return; const speech: SparringSpeech = { @@ -218,6 +234,8 @@ export default function SparringScreen() { setRequest({ status: 'idle' }); if (!next) return; commit(next); + // In a voice round the opponent speaks its speech aloud. + if (spoken && live.current) opponentVoice.speak({ feature: 'sparring', text: generated.content, voice, language: outputLanguage }); if (next.phase === 'judging') void runJudge(next, forUser); else if (currentSparringTurn(next)?.speaker === 'user') tapTurnStart(); } catch (cause) { @@ -250,7 +268,8 @@ export default function SparringScreen() { }, [commit, timerRunning]); const begin = (side: SparringSide, debateId: string | null, forUser: string) => { - const started = startSparringSession({ motion, userSide: side, format, difficulty, debateId }, newSparringId(), Date.now()); + const started = startSparringSession({ motion, userSide: side, format, difficulty, debateId, ...(voiceMode ? { voice: true } : {}) }, newSparringId(), Date.now()); + if (voiceMode) opponentVoice.beginRound(); setNotice(''); setSubmissionError(''); commit(started); @@ -313,16 +332,23 @@ export default function SparringScreen() { if (live.current && owner.current === forUser && startAttempt.current === attempt) begin(side, debateId, forUser); }; - const submit = () => { - const latest = sessionRef.current; + const submit = async () => { + const before = 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; + if (!before || !userId || before.phase !== 'round' || currentSparringTurn(before)?.speaker !== 'user') return; Keyboard.dismiss(); - const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId }); + // Stopping also withdraws a start still waiting for permission; the last words land in the draft first. + if (capture.listening) await capture.stop(); + else void capture.stop(); + const latest = sessionRef.current; + if (!latest || latest.id !== before.id || latest.currentTurnIndex !== before.currentTurnIndex) return; + const delivery = voiceActiveRef.current ? measureDelivery(capture.getTake(), outputLanguage) : null; + const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId, delivery }); if (!next) { setSubmissionError(t('sparring.round.speechRejected')); return; } + capture.resetTake(); setSubmissionError(''); commit(next); if (next.phase === 'judging') void runJudge(next, userId); @@ -338,6 +364,8 @@ export default function SparringScreen() { const restart = () => { requestRef.current?.abort(); + opponentVoice.stop(); + capture.cancel(); setRequest({ status: 'idle' }); setNotice(''); setSubmissionError(''); @@ -351,6 +379,33 @@ export default function SparringScreen() { if (next !== latest) commit(next); }; + // The first keystroke or word of a turn starts its clock, and talks over the opponent's voice. + const updateDraft = (draft: string) => move((round, now) => { + const turn = currentSparringTurn(round); + const key = `${round.id}:${turn?.id ?? ''}`; + const awaitingStart = startedTurn.current !== key && !round.timerRunning + && round.timerRemainingSeconds === turn?.timeLimitSeconds; + if (awaitingStart) { + startedTurn.current = key; + opponentVoice.stop(); + } + return { ...(awaitingStart ? resumeSparringTimer(round, now) : round), draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }; + }); + const resume = () => { + opponentVoice.stop(); + move((round, now) => { + startedTurn.current = `${round.id}:${currentSparringTurn(round)?.id ?? ''}`; + return resumeSparringTimer(round, now); + }); + }; + + // Recognised words join the draft, so the student can see and fix them. + const capture = useSpeechCapture(outputLanguage, (text) => updateDraft(appendDictation(sessionRef.current?.draft ?? '', text, MAX_SPARRING_SPEECH_CHARS))); + // Each turn of each round is measured on its own, and a start still waiting for permission does not carry over. + const turnKey = session ? `${session.id}:${currentSparringTurn(session)?.id ?? ''}` : ''; + const cancelCapture = capture.cancel; + useEffect(() => cancelCapture, [cancelCapture, turnKey]); + if (!session) { const turns = buildSparringTurns(format, 'pro'); return ( @@ -406,17 +461,7 @@ export default function SparringScreen() { ))} - - {/* Voice rounds need speech recognition and the opponent's voice, which come with the development build. */} - {}} - disabled - /> - + {formError ? ( @@ -460,25 +505,26 @@ export default function SparringScreen() { request={request} notice={notice} submissionError={submissionError} + voice={voiceActive ? { + capture, + // Speaking starts the turn's clock and talks over the opponent. + onStart: () => { + resume(); + void capture.start(); + }, + onStop: () => void capture.stop(), + } : null} onClose={() => router.back()} - onDraft={(draft) => move((round, now) => { - const turn = currentSparringTurn(round); - const key = `${round.id}:${turn?.id ?? ''}`; - const awaitingStart = startedTurn.current !== key && !round.timerRunning - && round.timerRemainingSeconds === turn?.timeLimitSeconds; - if (awaitingStart) startedTurn.current = key; - return { ...(awaitingStart ? resumeSparringTimer(round, now) : round), draft: draft.slice(0, MAX_SPARRING_SPEECH_CHARS), updatedAt: now }; - })} + onDraft={updateDraft} onPause={() => move(pauseSparringTimer)} - onResume={() => move((round, now) => { - startedTurn.current = `${round.id}:${currentSparringTurn(round)?.id ?? ''}`; - return resumeSparringTimer(round, now); - })} + onResume={resume} onReset={() => { + // A microphone still listening would keep adding untimed words to the reset turn. + void capture.stop(); startedTurn.current = null; move(resetSparringTurnTimer); }} - onSubmit={submit} + onSubmit={() => void submit()} onRetry={retry} /> ); @@ -487,6 +533,7 @@ export default function SparringScreen() { /** 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, + voice, request, notice, submissionError, @@ -499,6 +546,8 @@ function LiveRound({ onRetry, }: { session: SparringSession; + /** A voice round's microphone, while voice is available. */ + voice: { capture: SpeechCaptureApi; onStart: () => void; onStop: () => void } | null; request: RequestState; notice: string; submissionError: string; @@ -544,7 +593,14 @@ function LiveRound({ 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 listening = voice?.capture.listening ?? false; + const canSubmit = userTurn && (Boolean(session.draft.trim()) || listening) && !timeExpired; + // The microphone closes when the student's time or turn is over. + const micAllowed = userTurn && !timeExpired; + const stopMic = voice?.onStop; + useEffect(() => { + if (!micAllowed) stopMic?.(); + }, [micAllowed, stopMic]); const pressure = t(`sparring.difficulty.${session.difficulty}`); const main = failure @@ -599,6 +655,8 @@ function LiveRound({ )} + {voice ? : null} + {notice ? ( {notice} @@ -652,12 +710,13 @@ function LiveRound({ {guidance} + {voice ? : null} speech.speaker === 'user' && speech.delivery); + const spokenRound = spokenSpeeches.length > 0; return ( @@ -202,12 +206,16 @@ export function SparringScorecardView({ session, scorecard, transcriptOpen }: { - + {scorecard.categories.map((category) => ( {t(`sparring.category.${category.name}`)} - {category.name === 'Delivery' ? {` ${t('sparring.scorecard.writtenOnly')}`} : null} + {category.name === 'Delivery' ? ( + {` ${spokenRound ? t('voiceRound.scorecard.measuredOnly') : t('sparring.scorecard.writtenOnly')}`} + ) : null} {category.feedback} @@ -216,6 +224,19 @@ export function SparringScorecardView({ session, scorecard, transcriptOpen }: { ))} + {spokenRound ? ( + + {spokenSpeeches.map((speech) => ( + + + {t(`sparring.turns.${speech.turnId}`)} + + + + ))} + + ) : null} + {scorecard.keyMoments.length > 0 ? : {t('sparring.scorecard.noKeyMoments')}} diff --git a/apps/mobile/src/components/voice.tsx b/apps/mobile/src/components/voice.tsx new file mode 100644 index 000000000..81572b9eb --- /dev/null +++ b/apps/mobile/src/components/voice.tsx @@ -0,0 +1,371 @@ +import { formatRoundTime } from '@debatelab/core/sparring/formats'; +import { LONG_PAUSE_SECONDS, type SpeechDelivery } from '@debatelab/core/voice/delivery'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Linking, Pressable, StyleSheet, View } from 'react-native'; + +import { PrimaryButton, SecondaryButton, TextButton } from './buttons'; +import { FormGroup, SwitchRow } from './form-rows'; +import { Icon } from './icon'; +import { Text } from './text'; + +import { haptics } from '@/lib/haptics'; +import { opponentVoice, useOpponentVoice } from '@/lib/opponent-voice'; +import type { SpeechCaptureError } from '@/lib/speech-capture'; +import type { SpeechCaptureApi } from '@/lib/use-speech-capture'; +import { acknowledgeVoiceNotice, useVoiceAvailability } from '@/lib/voice-availability'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +// The website's voice controls (src/components/features/voice/), drawn the +// app's way. The wording is the website's, except where it names a browser: +// on the phone it is Apple's recognizer, Settings and the device's voice. + +const ERROR_KEYS: Record = { + 'not-allowed': 'mobile.voice.notAllowed', + 'no-microphone': 'voiceRound.mic.noMicrophone', + network: 'voiceRound.mic.network', + 'start-failed': 'voiceRound.mic.startFailed', +}; + +/** + * Turns a round into a spoken one, as the website's VoiceModeToggle. Where + * voice cannot run (Expo Go, or the microphone refused in Settings) the switch + * stays off with the reason. The first time it is switched on, it says where + * the audio and the text go, and waits for the student to agree. + */ +export function VoiceModeSwitch({ value, onChange, description }: { value: boolean; onChange: (value: boolean) => void; description: string }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const availability = useVoiceAvailability(); + const [notice, setNotice] = useState(false); + + if (!availability.available) { + const reason = availability.reason === 'denied' ? t('mobile.voice.denied') : t('mobile.crossfire.voiceLater'); + return ( + + {}} disabled /> + {availability.reason === 'denied' ? ( + + void Linking.openSettings()} /> + + ) : null} + + ); + } + + return ( + + + { + if (!next) { + setNotice(false); + onChange(false); + } else if (availability.noticeAcknowledged) { + onChange(true); + } else { + setNotice(true); + } + }} + /> + + {notice ? ( + + + + + {t('voiceRound.notice.title')} + + + {[t('voiceRound.notice.noRecording'), t('mobile.voice.recognition'), t('voiceRound.notice.opponent')].map((line) => ( + + + + {line} + + + ))} + + setNotice(false)} /> + { + acknowledgeVoiceNotice(); + setNotice(false); + haptics.success(); + onChange(true); + }} + testID="voice-notice-accept" + /> + + + ) : null} + + ); +} + +/** The one "listening" signal, as the website's: a steady red dot and a label. */ +function ListeningIndicator({ label }: { label: string }) { + const { colors } = useTheme(); + return ( + + + + {label} + + + ); +} + +/** The microphone button for a spoken turn, with the words heard so far, as the website's VoiceMicControl. */ +export function VoiceMicControl({ capture, onStart, onStop, disabled }: { capture: SpeechCaptureApi; onStart: () => void; onStop: () => void; disabled?: boolean }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const listening = capture.listening; + return ( + + + { + haptics.tap(); + if (listening) onStop(); + else onStart(); + }} + testID="voice-mic" + style={({ pressed }) => [ + styles.micButton, + listening + ? { backgroundColor: colors.destructive, borderColor: colors.destructive } + : { backgroundColor: colors.card, borderColor: colors.primaryFill }, + (pressed || disabled) && styles.dimmed, + ]}> + + + {listening ? t('voiceRound.mic.stop') : t('voiceRound.mic.start')} + + + {listening ? : null} + + {listening && capture.interim ? ( + + {capture.interim} + + ) : null} + {capture.error ? ( + + {t(ERROR_KEYS[capture.error])} + + ) : null} + + ); +} + +/** Shows that the opponent is speaking and lets the student stop it, as the website's OpponentVoiceBar. */ +export function OpponentVoiceBar() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const voice = useOpponentVoice(); + if (voice.status === 'idle' && !voice.notice) return null; + return ( + + {voice.status !== 'idle' ? ( + + + + + {voice.status === 'loading' ? t('voiceRound.opponent.loading') : t('voiceRound.opponent.speaking')} + + + + + ) : null} + {voice.notice === 'limit' ? ( + + {t('mobile.voice.limit')} + + ) : null} + + ); +} + +/** The measured facts of a spoken turn, as the website's DeliveryFacts: numbers, nothing about how the speaker sounded. */ +export function DeliveryFacts({ delivery, timeLimitSeconds }: { delivery: SpeechDelivery; timeLimitSeconds?: number }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const facts: [string, string][] = [ + [ + t('voiceRound.delivery.speakingTime'), + timeLimitSeconds + ? t('voiceRound.delivery.timeOfLimit', { used: formatRoundTime(delivery.speakingSeconds), limit: formatRoundTime(timeLimitSeconds) }) + : formatRoundTime(delivery.speakingSeconds), + ], + [t('voiceRound.delivery.pace'), t('voiceRound.delivery.wordsPerMinute', { count: delivery.wordsPerMinute })], + [ + t('voiceRound.delivery.fillers'), + delivery.fillers.length > 0 + ? `${delivery.fillerCount} (${delivery.fillers.map((filler) => `${filler.word} × ${filler.count}`).join(', ')})` + : String(delivery.fillerCount), + ], + [ + t('voiceRound.delivery.longPauses', { seconds: LONG_PAUSE_SECONDS }), + delivery.longPauses > 0 + ? t('voiceRound.delivery.pausesWithLongest', { count: delivery.longPauses, seconds: delivery.longestPauseSeconds }) + : String(delivery.longPauses), + ], + ]; + return ( + + {facts.map(([label, value], index) => ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + {label} + + + {value} + + + ))} + + ); +} + +/** The spoken delivery of a whole round, with the website's explanation of what was measured. */ +export function DeliverySummary({ delivery }: { delivery: SpeechDelivery }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + + + {t('voiceRound.delivery.title')} + + + + {t('mobile.voice.deliveryDescription')} + + + + ); +} + +/** + * A field's dictation button, as the website's microphone beside Generate's + * motion and Ask's question. It shows only where speech recognition exists. + */ +export function DictationButton({ capture, startLabel, stopLabel, inline }: { capture: SpeechCaptureApi; startLabel: string; stopLabel: string; inline?: boolean }) { + const { colors } = useTheme(); + if (!capture.available) return null; + const listening = capture.listening; + if (inline) { + // Inside a composer, beside the paperclip: an icon that turns red while listening. + return ( + { + haptics.tap(); + if (listening) void capture.stop(); + else void capture.start(); + }} + hitSlop={6} + testID="dictate" + style={({ pressed }) => [styles.dictateInline, pressed && styles.dimmed]}> + + + ); + } + return ( + { + haptics.tap(); + if (listening) void capture.stop(); + else void capture.start(); + }} + hitSlop={6} + testID="dictate" + style={({ pressed }) => [ + styles.dictate, + listening ? { backgroundColor: colors.destructive, borderColor: colors.destructive } : { backgroundColor: colors.card, borderColor: colors.border }, + pressed && styles.dimmed, + ]}> + + + ); +} + +/** What a dictated field says under it: listening, the words on their way, or why it stopped. */ +export function DictationStatus({ capture, listeningLabel }: { capture: SpeechCaptureApi; listeningLabel: string }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + if (!capture.listening && !capture.error) return null; + return ( + + {capture.listening ? : null} + {capture.listening && capture.interim ? ( + + {capture.interim} + + ) : null} + {capture.error ? ( + + {capture.error === 'not-allowed' ? t('mobile.voice.notAllowed') : t('voice.startFailed')} + + ) : null} + + ); +} + +/** Joins dictated words onto what is already in a field. */ +export function appendDictation(current: string, text: string, max = Infinity): string { + return `${current}${current && !/\s$/.test(current) ? ' ' : ''}${text}`.slice(0, max); +} + +const styles = StyleSheet.create({ + modeGroup: { gap: 12 }, + settingsRow: { paddingHorizontal: 15, paddingBottom: 12 }, + notice: { borderWidth: 2, borderRadius: radius.card, padding: 16, gap: 10 }, + noticeTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + noticeTitle: { fontSize: 18, flexShrink: 1 }, + bullet: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' }, + bulletDot: { width: 6, height: 6, borderRadius: 3, marginTop: 8 }, + bulletText: { flex: 1, fontSize: 15, lineHeight: 21 }, + noticeActions: { flexDirection: 'row', gap: 10, marginTop: 4, flexWrap: 'wrap' }, + mic: { gap: 6 }, + micRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap' }, + micButton: { flexDirection: 'row', alignItems: 'center', gap: 8, borderWidth: 2, borderRadius: 999, paddingHorizontal: 16, minHeight: 44 }, + micLabel: { fontSize: 15.5 }, + dimmed: { opacity: 0.55 }, + listening: { flexDirection: 'row', alignItems: 'center', gap: 7 }, + listeningDot: { width: 9, height: 9, borderRadius: 5 }, + listeningText: { fontSize: 14.5 }, + interim: { fontSize: 15, lineHeight: 21, fontStyle: 'italic' }, + error: { fontSize: 14.5, lineHeight: 20 }, + voiceBar: { borderWidth: 2, borderRadius: radius.card, paddingHorizontal: 14, paddingVertical: 10, gap: 6 }, + voiceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10 }, + voiceStatus: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 }, + voiceText: { fontSize: 15, flexShrink: 1 }, + voiceNote: { fontSize: 13.5, lineHeight: 19 }, + fact: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, paddingVertical: 8 }, + factLabel: { fontSize: 14.5, flexShrink: 1 }, + factValue: { fontSize: 14.5, textAlign: 'right', fontVariant: ['tabular-nums'], flexShrink: 1 }, + summary: { borderWidth: 2, borderRadius: radius.card, paddingHorizontal: 16, paddingVertical: 14, gap: 6 }, + summaryTitle: { fontSize: 16.5 }, + summaryText: { fontSize: 14, lineHeight: 19 }, + dictateInline: { width: 32, height: 36, alignItems: 'center', justifyContent: 'center' }, + dictate: { width: 46, height: 46, borderRadius: 23, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 7271e11db..995a9f6f0 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -15,6 +15,7 @@ import { } from '@debatelab/core/crossfire/schemas'; import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; +import { parseVoicePayload, type VoicePayload } from '@debatelab/core/voice/payload'; import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import { debateListScopes } from './debates'; import type { FeatureConfig } from './feature-config'; @@ -53,6 +54,8 @@ interface RequestOptions { * (SESSION_QUOTA) instead of every turn. Signals are not charged per round. */ quotaSession?: string; + /** A voice round: ask for a reply the opponent's voice can perform. */ + voice?: boolean; } // Turn signals and measured delivery facts are client annotations. The Edge @@ -72,6 +75,8 @@ function requestBody(input: Record & { transcript: CrossfireTur // Servers from before Current Debate context read only this field. sourceIds: options.debateContext?.sourceIds ?? [], ...(options.debateContext ? { debateContext: options.debateContext } : {}), + // Beside `input`, as the website sends it, and only for turns: a server from before voice rounds ignores it. + ...(options.voice && input.operation === 'next-turn' ? { voice: true } : {}), }; } @@ -108,7 +113,7 @@ export async function withTurnDeadline(signal: AbortSignal, work: (signal: Ab } } -export type CrossfireNextTurn = CrossfireReply & { signalToken?: string }; +export type CrossfireNextTurn = CrossfireReply & { signalToken?: string; voice?: VoicePayload }; /** The opponent's next question or answer, checked as the website checks it. */ export function crossfireTurn( @@ -125,7 +130,12 @@ export function crossfireTurn( if (token !== undefined && (typeof token !== 'string' || !token.trim() || token.length > 128)) { throw new CrossfireResponseError('Crossfire signal token was invalid.'); } - return { reply: typeof token === 'string' ? { ...reply.data, signalToken: token } : reply.data, ...responseExtras(data) }; + // A missing or malformed voice payload only means the device voice reads the reply. + const voice = options.voice ? parseVoicePayload(data.voice) : undefined; + return { + reply: { ...reply.data, ...(typeof token === 'string' ? { signalToken: token } : {}), ...(voice ? { voice } : {}) }, + ...responseExtras(data), + }; }, TURN_TIMEOUT_MS); } diff --git a/apps/mobile/src/lib/opponent-voice.ts b/apps/mobile/src/lib/opponent-voice.ts new file mode 100644 index 000000000..976a43264 --- /dev/null +++ b/apps/mobile/src/lib/opponent-voice.ts @@ -0,0 +1,246 @@ +import { toIntlLocale, type AppLanguage } from '@debatelab/core/languages'; +import { stripAudioTags } from '@debatelab/core/contract/voice-tags'; +import type { VoiceFeature, VoicePayload, VoiceSegment } from '@debatelab/core/voice/payload'; +import { createAudioPlayer, setAudioModeAsync, type AudioPlayer } from 'expo-audio'; +import { File, Paths } from 'expo-file-system'; +import * as Speech from 'expo-speech'; +import { useSyncExternalStore } from 'react'; + +import { supabase } from './supabase'; +import { supabaseProject } from './supabase-config'; + +// The opponent's voice, as the website's src/lib/voice/opponent-voice.ts. +// ElevenLabs speaks through DebateOwl's `voice` Edge Function, one signed +// segment at a time; whenever that is not possible (no key on the server, +// the daily allowance is used up, the call fails) iOS's own voice reads the +// same reply and the round goes on. Nothing is recorded: the clip is played +// from the cache and deleted. + +export interface OpponentUtterance { + feature: VoiceFeature; + /** The reply as shown on screen: no audio tags. */ + text: string; + /** Signed segments for ElevenLabs. Without them the device voice reads `text`. */ + voice?: VoicePayload; + language: AppLanguage; +} + +export type VoiceStatus = 'idle' | 'loading' | 'speaking'; +/** Why the device voice took over, when the student should know. */ +export type VoiceNotice = 'limit' | null; + +export interface OpponentVoiceState { + status: VoiceStatus; + engine: 'elevenlabs' | 'device' | null; + notice: VoiceNotice; +} + +const REQUEST_TIMEOUT_MS = 30_000; + +class VoiceRequestError extends Error { + constructor(readonly status: number, readonly code: string | undefined) { + super(`Voice request failed (${status})`); + this.name = 'VoiceRequestError'; + } +} + +async function requestSegmentAudio(feature: VoiceFeature, segment: VoiceSegment, signal: AbortSignal): Promise { + const token = (await supabase.auth.getSession()).data.session?.access_token; + if (!token) throw new VoiceRequestError(401, undefined); + const controller = new AbortController(); + const cancel = () => controller.abort(); + const timeout = setTimeout(cancel, REQUEST_TIMEOUT_MS); + signal.addEventListener('abort', cancel); + try { + const response = await fetch(`${supabaseProject.url}/functions/v1/voice`, { + method: 'POST', + headers: { apikey: supabaseProject.anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ feature, text: segment.text, token: segment.token }), + signal: controller.signal, + }); + if (!response.ok) { + const body: unknown = await response.json().catch((): null => null); + const code = body && typeof body === 'object' && typeof (body as { code?: unknown }).code === 'string' ? (body as { code: string }).code : undefined; + throw new VoiceRequestError(response.status, code); + } + return new Uint8Array(await response.arrayBuffer()); + } finally { + clearTimeout(timeout); + signal.removeEventListener('abort', cancel); + } +} + +let clipNumber = 0; + +class OpponentVoice { + private state: OpponentVoiceState = { status: 'idle', engine: null, notice: null }; + private listeners = new Set<() => void>(); + private player: AudioPlayer | null = null; + private abort: AbortController | null = null; + private run = 0; + private onEnd: (() => void) | null = null; + /** Set once the server says ElevenLabs cannot serve this round. */ + private providerOff = false; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getState = (): OpponentVoiceState => this.state; + + private setState(patch: Partial) { + this.state = { ...this.state, ...patch }; + this.listeners.forEach((listener) => listener()); + } + + /** + * Start a round. A failure that asking again would not fix hands the + * opponent to the device voice for the rest of a round, so a broken or + * exhausted provider costs at most one failed request per round. + */ + beginRound = () => { + this.stop(); + this.providerOff = false; + if (this.state.notice) this.setState({ notice: null }); + }; + + /** Speak one reply. `onEnd` runs once, when it finishes or is stopped. */ + speak(utterance: OpponentUtterance, onEnd?: () => void) { + this.stop(); + const run = ++this.run; + this.onEnd = onEnd ?? null; + this.abort = new AbortController(); + void this.play(utterance, run, this.abort.signal) + .catch((cause: unknown) => console.warn('The opponent voice failed:', cause instanceof Error ? cause.message : cause)) + .finally(() => this.finish(run)); + } + + stop = () => { + this.abort?.abort(); + this.abort = null; + this.releasePlayer(); + void Speech.stop().catch(() => {}); + this.finish(this.run); + }; + + private releasePlayer() { + const player = this.player; + this.player = null; + if (!player) return; + try { + player.pause(); + player.remove(); + } catch { + // Already released. + } + } + + private finish(run: number) { + if (run !== this.run) return; + // Late callbacks from this reply are ignored from here on. + this.run += 1; + const onEnd = this.onEnd; + this.onEnd = null; + if (this.state.status !== 'idle') this.setState({ status: 'idle', engine: null }); + onEnd?.(); + } + + private async play(utterance: OpponentUtterance, run: number, signal: AbortSignal) { + const segments = utterance.voice?.segments ?? []; + let next = 0; + // Speech recognition leaves the audio session in record mode; play through the speaker, also on silent. + await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: false, interruptionMode: 'duckOthers' }).catch(() => {}); + if (run !== this.run) return; + if (segments.length > 0 && !this.providerOff) { + this.setState({ status: 'loading', engine: 'elevenlabs', notice: null }); + // Fetch one segment ahead so speech does not stall between segments. + let pending = this.load(utterance.feature, segments[0], signal); + for (; next < segments.length; next += 1) { + const clip = await pending; + if (run !== this.run) return; + if (!clip) break; + pending = next + 1 < segments.length ? this.load(utterance.feature, segments[next + 1], signal) : Promise.resolve(null); + const played = await this.playClip(clip, run); + if (!played) return; + } + } + if (run !== this.run) return; + // The device voice reads whatever ElevenLabs did not: the rest of the + // segments without their tags and any unsigned overflow, or the whole reply. + const remaining = segments.length > 0 + ? [...segments.slice(next).map((segment) => stripAudioTags(segment.text)), utterance.voice?.overflow ?? ''].join(' ').trim() + : utterance.text; + await this.speakWithDevice(remaining, utterance.language, run); + } + + private async load(feature: VoiceFeature, segment: VoiceSegment, signal: AbortSignal): Promise { + if (this.providerOff) return null; + try { + const bytes = await requestSegmentAudio(feature, segment, signal); + const file = new File(Paths.cache, `opponent-voice-${Date.now()}-${clipNumber++}.mp3`); + file.create({ overwrite: true }); + file.write(bytes); + return file; + } catch (error) { + if (signal.aborted) return null; + // The server refused: not configured, not allowed, or ElevenLabs failed. + // Asking again this round would likely fail the same way and may spend + // a clip, so the device voice takes the rest of the round. A refused + // token concerns one reply only; a network error is retried next reply. + if (error instanceof VoiceRequestError && error.code !== 'voice_token_invalid') { + this.providerOff = true; + if (error.code === 'voice_limit_reached') this.setState({ notice: 'limit' }); + } + return null; + } + } + + private async playClip(clip: File, run: number): Promise { + try { + if (run !== this.run) return false; + this.setState({ status: 'speaking', engine: 'elevenlabs' }); + const player = createAudioPlayer(clip.uri); + this.player = player; + await new Promise((resolve) => { + const subscription = player.addListener('playbackStatusUpdate', (status) => { + if (!status.didJustFinish) return; + subscription.remove(); + resolve(); + }); + // Stopping releases the player; the run check below ends the wait. + const check = setInterval(() => { + if (run === this.run && this.player === player) return; + clearInterval(check); + subscription.remove(); + resolve(); + }, 200); + player.play(); + }); + if (this.player === player) this.releasePlayer(); + return run === this.run; + } finally { + try { + clip.delete(); + } catch { + // The cache is cleared by iOS anyway. + } + } + } + + private async speakWithDevice(text: string, language: AppLanguage, run: number) { + if (!text.trim() || run !== this.run) return; + this.setState({ status: 'speaking', engine: 'device' }); + await new Promise((resolve) => { + Speech.speak(text, { language: toIntlLocale(language), onDone: resolve, onStopped: resolve, onError: () => resolve() }); + }); + } +} + +export const opponentVoice = new OpponentVoice(); + +export function useOpponentVoice(): OpponentVoiceState { + return useSyncExternalStore(opponentVoice.subscribe, opponentVoice.getState, opponentVoice.getState); +} diff --git a/apps/mobile/src/lib/opponent-voice.web.ts b/apps/mobile/src/lib/opponent-voice.web.ts new file mode 100644 index 000000000..381bbc303 --- /dev/null +++ b/apps/mobile/src/lib/opponent-voice.web.ts @@ -0,0 +1,58 @@ +import { useSyncExternalStore } from 'react'; + +import type { OpponentUtterance, OpponentVoiceState } from './opponent-voice'; + +export type { OpponentUtterance, OpponentVoiceState }; + +/** + * The web preview never calls the voice function or plays audio. It keeps + * each reply it would speak on `window` for the screenshot script and shows + * the opponent speaking for two seconds of the page's clock. The phone app + * uses opponent-voice.ts. + */ +class PreviewVoice { + private state: OpponentVoiceState = { status: 'idle', engine: null, notice: null }; + private listeners = new Set<() => void>(); + private timer: ReturnType | null = null; + private onEnd: (() => void) | null = null; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getState = (): OpponentVoiceState => this.state; + + private set(state: OpponentVoiceState) { + this.state = state; + this.listeners.forEach((listener) => listener()); + } + + beginRound = () => this.stop(); + + speak(utterance: OpponentUtterance, onEnd?: () => void) { + this.stop(); + const target = window as unknown as { __debatelabOpponentVoice?: { feature: string; segments: number }[] }; + (target.__debatelabOpponentVoice ??= []).push({ feature: utterance.feature, segments: utterance.voice?.segments.length ?? 0 }); + this.onEnd = onEnd ?? null; + this.set({ status: 'speaking', engine: utterance.voice ? 'elevenlabs' : 'device', notice: null }); + this.timer = setTimeout(() => this.stop(), 2_000); + } + + stop = () => { + if (this.timer) clearTimeout(this.timer); + this.timer = null; + const onEnd = this.onEnd; + this.onEnd = null; + if (this.state.status !== 'idle') this.set({ ...this.state, status: 'idle', engine: null }); + onEnd?.(); + }; +} + +export const opponentVoice = new PreviewVoice(); + +export function useOpponentVoice(): OpponentVoiceState { + return useSyncExternalStore(opponentVoice.subscribe, opponentVoice.getState, opponentVoice.getState); +} diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index 8aa07573c..85a322855 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -9,6 +9,7 @@ import { type SparringTurnResponse, } from '@debatelab/core/ai/schemas/sparring'; import { parseSavedSparringRows, SPARRING_SESSION_COLUMNS, type SparringSession } from '@debatelab/core/sparring/session'; +import { parseVoicePayload, type VoicePayload } from '@debatelab/core/voice/payload'; import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; @@ -40,6 +41,8 @@ interface RequestOptions { /** The debate the round belongs to; null for a one-off round. */ debateId: string | null; signal?: AbortSignal; + /** A voice round: the reply also carries segments the opponent's voice can perform. */ + voice?: boolean; } /** The website's request body. A live round stays transcript-only, so its debate context carries no sources or results. */ @@ -55,16 +58,24 @@ function requestBody(input: SparringRespondInput | SparringScoreInput, options: // Servers from before Current Debate context read only this field. sourceIds: [], ...(options.debateId ? { debateContext: { debateId: options.debateId, side: input.userSide, sourceIds: [], outputs: [] } } : {}), + // Beside `input`, as the website sends it, so a server from before voice rounds ignores it. + ...(options.voice ? { voice: true } : {}), }; } -/** The opponent's speech for the current turn, with the token the server signed it with. */ -export function sparringTurn(input: SparringRespondInput, options: RequestOptions): Promise { +/** + * The opponent's speech for the current turn, with the token the server + * signed it with, and in a voice round the signed segments the opponent's + * voice speaks. + */ +export function sparringTurn(input: SparringRespondInput, options: RequestOptions): Promise<{ speech: SparringTurnResponse['speech']; voice?: VoicePayload }> { return postFunction('ai', requestBody(input, options), options.signal, async (response) => { - const { output } = await functionOutput(response); + const { output, data } = 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; + // A missing or malformed voice payload only means the device voice reads the speech. + const voice = options.voice ? parseVoicePayload(data.voice) : undefined; + return voice ? { speech: parsed.data.speech, voice } : { speech: parsed.data.speech }; }); } @@ -73,7 +84,7 @@ export function sparringTurn(input: SparringRespondInput, options: RequestOption * sparring_sessions before it answers, and answers a repeated request with * the scorecard it saved. */ -export function judgeSparring(input: SparringScoreInput, options: RequestOptions): Promise { +export function judgeSparring(input: SparringScoreInput, options: Omit): Promise { return postFunction('ai', requestBody(input, options), options.signal, async (response) => { const { output } = await functionOutput(response); const parsed = SparringScoreResponseSchema.safeParse(output); diff --git a/apps/mobile/src/lib/speech-capture.test.ts b/apps/mobile/src/lib/speech-capture.test.ts new file mode 100644 index 000000000..63fd2e534 --- /dev/null +++ b/apps/mobile/src/lib/speech-capture.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SpeechCapture } from './speech-capture'; +import type { Recognizer } from './speech-recognition'; + +type Handler = (event: unknown) => void; + +function fakeRecognizer(granted = true) { + const handlers = new Map>(); + const fake = { + starts: 0, + stopBehaviour: 'end' as 'end' | 'silent', + emit: (event: string, payload: unknown = null) => handlers.get(event)?.forEach((handler) => handler(payload)), + heard: (text: string, isFinal = true) => fake.emit('result', { isFinal, results: [{ transcript: text, confidence: 1, segments: [] }] }), + recognizer: { + start: () => { + fake.starts += 1; + }, + stop: () => { + if (fake.stopBehaviour === 'end') queueMicrotask(() => fake.emit('end')); + }, + abort: () => {}, + requestPermissionsAsync: async () => ({ granted, canAskAgain: true }), + getPermissionsAsync: async () => ({ granted, canAskAgain: true, status: granted ? 'granted' : 'denied' }), + addListener: (event: string, handler: Handler) => { + const set = handlers.get(event) ?? new Set(); + set.add(handler); + handlers.set(event, set); + return { remove: () => set.delete(handler) }; + }, + } as unknown as Recognizer, + }; + return fake; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); +}); +afterEach(() => vi.useRealTimers()); + +describe('speech capture', () => { + it('keeps listening when iOS ends a session, and times the whole take with its long pauses', async () => { + const fake = fakeRecognizer(); + const finals: string[] = []; + const capture = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: (text) => finals.push(text) }); + expect(await capture.start()).toBe(true); + vi.advanceTimersByTime(2_000); + fake.heard('um the first'); + vi.advanceTimersByTime(4_000); + fake.heard('point is', false); + // iOS closes the request on its own; the interim words count and listening carries on. + fake.emit('end'); + expect(fake.starts).toBe(2); + expect(capture.getState().listening).toBe(true); + vi.advanceTimersByTime(1_000); + fake.heard('basically clear'); + vi.advanceTimersByTime(1_000); + await capture.stop(); + expect(finals).toEqual(['um the first', 'point is', 'basically clear']); + expect(capture.getTake()).toEqual({ text: 'um the first point is basically clear', speakingMs: 8_000, pausesMs: [4_000] }); + expect(capture.getState().listening).toBe(false); + }); + + it('gives up after three sessions that die at once', async () => { + const fake = fakeRecognizer(); + const capture = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: () => {} }); + await capture.start(); + fake.emit('end'); + fake.emit('end'); + fake.emit('end'); + expect(capture.getState()).toMatchObject({ listening: false, error: 'start-failed' }); + }); + + it('stops after a grace period when the last words never come', async () => { + const fake = fakeRecognizer(); + fake.stopBehaviour = 'silent'; + const capture = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: () => {} }); + await capture.start(); + const stopped = capture.stop(); + // A second caller joins the stop in progress. + expect(capture.stop()).toBe(stopped); + vi.advanceTimersByTime(1_500); + await stopped; + expect(capture.getState().listening).toBe(false); + }); + + it('reports a refused microphone and does not listen', async () => { + const fake = fakeRecognizer(false); + const onDenied = vi.fn(); + const capture = new SpeechCapture(fake.recognizer, { lang: 'hu-HU', onFinalText: () => {}, onDenied }); + expect(await capture.start()).toBe(false); + expect(fake.starts).toBe(0); + expect(onDenied).toHaveBeenCalledOnce(); + expect(capture.getState().error).toBe('not-allowed'); + }); + + it('drops a start that was still waiting for permission when the turn ends', async () => { + const fake = fakeRecognizer(); + const capture = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: () => {} }); + const starting = capture.start(); + capture.cancel(); + expect(await starting).toBe(false); + expect(fake.starts).toBe(0); + }); +}); diff --git a/apps/mobile/src/lib/speech-capture.ts b/apps/mobile/src/lib/speech-capture.ts new file mode 100644 index 000000000..b610581da --- /dev/null +++ b/apps/mobile/src/lib/speech-capture.ts @@ -0,0 +1,275 @@ +import { LONG_PAUSE_SECONDS, type SpeechTake } from '@debatelab/core/voice/delivery'; + +import type { Recognizer } from './speech-recognition'; + +// Speech capture for a spoken turn or a dictated field, as the website's +// useSpeechCapture (src/hooks/use-speech-capture.ts): a turn can run for +// minutes, so recognition starts again when iOS ends it (silence, or its own +// limit on one request), interim words show as they come, and the take is +// timed. The audio goes from the microphone to Apple's recognizer only; +// DebateOwl never receives or stores it. + +export type SpeechCaptureError = 'not-allowed' | 'no-microphone' | 'network' | 'start-failed'; + +export interface CaptureState { + listening: boolean; + /** Words heard but not yet final. */ + interim: string; + error: SpeechCaptureError | null; +} + +const STOP_GRACE_MS = 1_500; +/** A recognizer that keeps dying this quickly is not going to recover. */ +const MIN_HEALTHY_SESSION_MS = 750; +const MAX_QUICK_FAILURES = 3; + +export interface CaptureOptions { + /** BCP-47, such as en-US. */ + lang: string; + onFinalText: (text: string) => void; + /** The student refused the microphone or speech recognition. */ + onDenied?: () => void; + now?: () => number; +} + +const emptyTake = (): SpeechTake => ({ text: '', speakingMs: 0, pausesMs: [] }); + +export class SpeechCapture { + private state: CaptureState = { listening: false, interim: '', error: null }; + private listeners = new Set<() => void>(); + private subscriptions: { remove(): void }[] = []; + private want = false; + private take = emptyTake(); + private startedAt = 0; + private sessionStartedAt = 0; + private lastResultAt: number | null = null; + private quickFailures = 0; + private stopped: (() => void) | null = null; + private stopping: Promise | null = null; + // Bumped by every start, stop and cancel, so a start still waiting for the + // permission prompt can tell it is no longer wanted. + private attempt = 0; + + constructor(private recognizer: Recognizer, private options: CaptureOptions) {} + + private now() { + return this.options.now?.() ?? Date.now(); + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getState = (): CaptureState => this.state; + + private set(patch: Partial) { + this.state = { ...this.state, ...patch }; + this.listeners.forEach((listener) => listener()); + } + + setOptions(options: CaptureOptions) { + this.options = options; + } + + private commitFinal(text: string) { + const clean = text.trim(); + if (!clean) return; + this.take.text = this.take.text ? `${this.take.text} ${clean}` : clean; + this.options.onFinalText(clean); + } + + private detach() { + this.subscriptions.forEach((subscription) => subscription.remove()); + this.subscriptions = []; + } + + private finishListening() { + if (this.startedAt) this.take.speakingMs += this.now() - this.startedAt; + this.startedAt = 0; + // Words still marked interim when the recognizer closes were spoken. + this.commitFinal(this.state.interim); + this.detach(); + this.want = false; + this.set({ interim: '', listening: false }); + const stopped = this.stopped; + this.stopped = null; + this.stopping = null; + stopped?.(); + } + + private fail(reason: SpeechCaptureError) { + if (reason === 'not-allowed') this.options.onDenied?.(); + this.set({ error: reason }); + this.detach(); + try { + this.recognizer.abort(); + } catch { + // Already closed. + } + this.finishListening(); + } + + private begin() { + this.sessionStartedAt = this.now(); + this.recognizer.start({ + lang: this.options.lang, + interimResults: true, + continuous: true, + addsPunctuation: true, + iosTaskHint: 'dictation', + }); + } + + start = async (): Promise => { + if (this.want) return false; + this.set({ error: null }); + this.want = true; + const attempt = ++this.attempt; + let granted = false; + try { + // Ask up front, so a refusal is reported clearly. + granted = (await this.recognizer.requestPermissionsAsync()).granted; + } catch { + granted = false; + } + // The turn was submitted or ended while the permission prompt was open. + if (attempt !== this.attempt) return false; + if (!granted) { + this.want = false; + this.options.onDenied?.(); + this.set({ error: 'not-allowed' }); + return false; + } + + this.subscriptions = [ + this.recognizer.addListener('result', (event) => { + const now = this.now(); + const last = this.lastResultAt; + if (last !== null && now - last >= LONG_PAUSE_SECONDS * 1_000) this.take.pausesMs.push(now - last); + this.lastResultAt = now; + const transcript = event.results[0]?.transcript ?? ''; + if (event.isFinal) { + this.commitFinal(transcript); + this.set({ interim: '' }); + } else { + this.set({ interim: transcript.trim() }); + } + }), + this.recognizer.addListener('error', (event) => { + if (event.error === 'not-allowed' || event.error === 'service-not-allowed') this.fail('not-allowed'); + else if (event.error === 'audio-capture') this.fail('no-microphone'); + else if (event.error === 'network') this.fail('network'); + else if (event.error === 'language-not-supported' || event.error === 'bad-grammar') this.fail('start-failed'); + // no-speech, aborted, interrupted and the like end the session; 'end' decides what follows. + }), + this.recognizer.addListener('end', () => this.onEnd()), + ]; + this.quickFailures = 0; + this.lastResultAt = null; + this.startedAt = this.now(); + try { + this.begin(); + } catch { + this.fail('start-failed'); + return false; + } + this.set({ listening: true }); + return true; + }; + + private onEnd() { + if (!this.want) { + this.finishListening(); + return; + } + // iOS closed the session (silence, or its own limit) while the student is + // still on the clock. Carry on listening. + this.commitFinal(this.state.interim); + this.set({ interim: '' }); + this.quickFailures = this.now() - this.sessionStartedAt < MIN_HEALTHY_SESSION_MS ? this.quickFailures + 1 : 0; + if (this.quickFailures >= MAX_QUICK_FAILURES) { + this.fail('start-failed'); + return; + } + try { + this.begin(); + } catch { + this.fail('start-failed'); + } + } + + /** Stops listening and resolves once the last words have arrived. */ + stop = (): Promise => { + // A second caller (the Stop button, then Submit) joins the stop in progress. + if (this.stopping) return this.stopping; + this.want = false; + this.attempt += 1; + if (!this.state.listening) return Promise.resolve(); + // The recognizer delivers its last words after stop(), then ends. + const stopping = new Promise((resolve) => { + const timeout = setTimeout(() => { + try { + this.recognizer.abort(); + } catch { + // Already closed. + } + this.finishListening(); + }, STOP_GRACE_MS); + this.stopped = () => { + clearTimeout(timeout); + resolve(); + }; + try { + this.recognizer.stop(); + } catch { + this.finishListening(); + } + }); + // finishListening may already have run (a recognizer that ended at once) and released it. + if (this.stopped) this.stopping = stopping; + return stopping; + }; + + /** + * Drops everything: a start still waiting for permission, the open + * recognizer, pending words and the take. For a turn or round that ends + * some other way than by submitting what was said. + */ + cancel = () => { + this.attempt += 1; + this.want = false; + const wasListening = this.state.listening; + this.detach(); + if (wasListening) { + try { + this.recognizer.abort(); + } catch { + // Already closed. + } + } + this.startedAt = 0; + this.lastResultAt = null; + this.take = emptyTake(); + this.set({ interim: '', listening: false }); + // Anyone waiting on a stop is released; there is nothing left to wait for. + const stopped = this.stopped; + this.stopped = null; + this.stopping = null; + stopped?.(); + }; + + /** Everything captured since the last reset, across pauses and restarts. */ + getTake = (): SpeechTake => ({ + ...this.take, + pausesMs: [...this.take.pausesMs], + speakingMs: this.take.speakingMs + (this.startedAt ? this.now() - this.startedAt : 0), + }); + + resetTake = () => { + this.take = emptyTake(); + this.lastResultAt = null; + }; +} diff --git a/apps/mobile/src/lib/speech-recognition.ts b/apps/mobile/src/lib/speech-recognition.ts new file mode 100644 index 000000000..96c3f90c1 --- /dev/null +++ b/apps/mobile/src/lib/speech-recognition.ts @@ -0,0 +1,36 @@ +import type { ExpoSpeechRecognitionNativeEventMap, ExpoSpeechRecognitionOptions } from 'expo-speech-recognition'; + +import { nativeCapabilities } from './native-capabilities'; + +/** + * The few calls the app makes on iOS's speech recognizer, so speech-capture.ts + * can run on a fake in tests and in the web preview. + */ +export interface Recognizer { + start(options: ExpoSpeechRecognitionOptions): void; + stop(): void; + abort(): void; + requestPermissionsAsync(): Promise<{ granted: boolean; canAskAgain: boolean }>; + getPermissionsAsync(): Promise<{ granted: boolean; canAskAgain: boolean; status: string }>; + addListener(event: E, listener: (event: ExpoSpeechRecognitionNativeEventMap[E]) => void): { remove(): void }; +} + +let recognizer: Recognizer | null | undefined; + +/** + * expo-speech-recognition, in a build that has it (the development build); + * null in Expo Go, which is never asked for the module. + */ +export function speechRecognizer(): Recognizer | null { + if (recognizer !== undefined) return recognizer; + recognizer = null; + if (!nativeCapabilities().speechRecognition) return null; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ExpoSpeechRecognitionModule } = require('expo-speech-recognition') as typeof import('expo-speech-recognition'); + recognizer = ExpoSpeechRecognitionModule as unknown as Recognizer; + } catch (cause) { + console.warn('Speech recognition is unavailable:', cause instanceof Error ? cause.message : cause); + } + return recognizer; +} diff --git a/apps/mobile/src/lib/speech-recognition.web.ts b/apps/mobile/src/lib/speech-recognition.web.ts new file mode 100644 index 000000000..17ced1bfb --- /dev/null +++ b/apps/mobile/src/lib/speech-recognition.web.ts @@ -0,0 +1,58 @@ +import type { Recognizer } from './speech-recognition'; + +export type { Recognizer }; + +type Listener = (event: unknown) => void; + +/** + * The web preview has no iOS speech recognizer. When the screenshot script + * sets `window.__debatelabFakeSpeech` (phrases, each heard after its delay in + * ms on the page's clock), this fake recognizer "hears" them, so the voice + * round's screens can be shot; otherwise voice shows as unavailable, as in + * Expo Go. The phone app uses speech-recognition.ts. + */ +type FakeSpeech = { phrases: { text: string; afterMs: number }[] }; + +function fakeRecognizer(script: FakeSpeech): Recognizer { + const listeners = new Map>(); + const emit = (event: string, payload: unknown) => listeners.get(event)?.forEach((listener) => listener(payload)); + let timers: ReturnType[] = []; + let next = 0; + const end = () => { + timers.forEach(clearTimeout); + timers = []; + setTimeout(() => emit('end', null), 0); + }; + return { + start() { + let at = 0; + for (const phrase of script.phrases.slice(next)) { + at += phrase.afterMs; + timers.push(setTimeout(() => { + next += 1; + emit('result', { isFinal: false, results: [{ transcript: phrase.text.split(' ').slice(0, 2).join(' '), confidence: 0, segments: [] }] }); + emit('result', { isFinal: true, results: [{ transcript: phrase.text, confidence: 1, segments: [] }] }); + }, at)); + } + }, + stop: end, + abort: end, + requestPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + getPermissionsAsync: async () => ({ granted: true, canAskAgain: true, status: 'granted' }), + addListener(event, listener) { + const set = listeners.get(event) ?? new Set(); + set.add(listener as Listener); + listeners.set(event, set); + return { remove: () => set.delete(listener as Listener) }; + }, + }; +} + +let recognizer: Recognizer | null = null; + +export function speechRecognizer(): Recognizer | null { + const script = (window as unknown as { __debatelabFakeSpeech?: FakeSpeech }).__debatelabFakeSpeech; + if (!script) return null; + recognizer ??= fakeRecognizer(script); + return recognizer; +} diff --git a/apps/mobile/src/lib/use-speech-capture.ts b/apps/mobile/src/lib/use-speech-capture.ts new file mode 100644 index 000000000..8ee6cbaa3 --- /dev/null +++ b/apps/mobile/src/lib/use-speech-capture.ts @@ -0,0 +1,56 @@ +import { toIntlLocale, type AppLanguage } from '@debatelab/core/languages'; +import type { SpeechTake } from '@debatelab/core/voice/delivery'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import { SpeechCapture, type CaptureState } from './speech-capture'; +import { speechRecognizer } from './speech-recognition'; +import { markVoiceDenied } from './voice-availability'; + +export interface SpeechCaptureApi extends CaptureState { + /** False in Expo Go and wherever speech recognition is missing. */ + available: boolean; + start: () => Promise; + stop: () => Promise; + cancel: () => void; + getTake: () => SpeechTake; + resetTake: () => void; +} + +const IDLE: CaptureState = { listening: false, interim: '', error: null }; +const noop = () => () => {}; +const idle = () => IDLE; + +/** + * Speech capture for a screen, in `language`: a spoken turn or a dictated + * field. Recognised words go to `onFinalText` as they become final. Leaving + * the screen drops whatever was still being heard. + */ +export function useSpeechCapture(language: AppLanguage, onFinalText: (text: string) => void): SpeechCaptureApi { + const onFinal = useRef(onFinalText); + onFinal.current = onFinalText; + const [capture] = useState(() => { + const recognizer = speechRecognizer(); + return recognizer + ? new SpeechCapture(recognizer, { lang: toIntlLocale(language), onFinalText: (text) => onFinal.current(text), onDenied: markVoiceDenied }) + : null; + }); + // A new language applies from the next start. + useEffect(() => { + capture?.setOptions({ lang: toIntlLocale(language), onFinalText: (text) => onFinal.current(text), onDenied: markVoiceDenied }); + }, [capture, language]); + useEffect(() => () => capture?.cancel(), [capture]); + + const state = useSyncExternalStore(capture?.subscribe ?? noop, capture?.getState ?? idle, capture?.getState ?? idle); + if (!capture) { + return { + ...IDLE, + available: false, + start: async () => false, + stop: async () => {}, + cancel: () => {}, + getTake: () => ({ text: '', speakingMs: 0, pausesMs: [] }), + resetTake: () => {}, + }; + } + return { ...state, available: true, start: capture.start, stop: capture.stop, cancel: capture.cancel, getTake: capture.getTake, resetTake: capture.resetTake }; +} diff --git a/apps/mobile/src/lib/voice-availability.ts b/apps/mobile/src/lib/voice-availability.ts new file mode 100644 index 000000000..d89dbaf73 --- /dev/null +++ b/apps/mobile/src/lib/voice-availability.ts @@ -0,0 +1,90 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect, useSyncExternalStore } from 'react'; +import { AppState } from 'react-native'; + +import { speechRecognizer } from './speech-recognition'; + +// Whether voice can be used here, as the website's src/lib/voice/availability.ts: +// speech recognition must be in the build (never in Expo Go), and the +// microphone and speech recognition must not be refused in Settings. The +// first-use privacy notice is remembered on the device, as the website +// remembers it in the browser. + +export type VoiceUnavailableReason = 'unsupported' | 'denied'; + +const NOTICE_KEY = 'debatelab.voice-notice-v1'; + +let state = { denied: false, noticeAcknowledged: false }; +let noticeRead: Promise | null = null; +const listeners = new Set<() => void>(); + +function set(patch: Partial) { + state = { ...state, ...patch }; + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const snapshot = () => state; + +function readNotice() { + noticeRead ??= AsyncStorage.getItem(NOTICE_KEY) + .then((value) => { + if (value === 'acknowledged') set({ noticeAcknowledged: true }); + }) + .catch(() => {}); + return noticeRead; +} + +/** The student refused the microphone or speech recognition. */ +export function markVoiceDenied() { + set({ denied: true }); +} + +export function acknowledgeVoiceNotice() { + set({ noticeAcknowledged: true }); + AsyncStorage.setItem(NOTICE_KEY, 'acknowledged').catch(() => {}); +} + +/** Reads Settings again: a student may have allowed the microphone since. */ +async function checkPermission() { + const recognizer = speechRecognizer(); + if (!recognizer) return; + try { + const permission = await recognizer.getPermissionsAsync(); + // A refusal iOS will not ask about again; one it still asks about is not a refusal yet. + set({ denied: !permission.granted && !permission.canAskAgain }); + } catch { + // Unknown: leave it as it was. + } +} + +export interface VoiceAvailability { + available: boolean; + reason: VoiceUnavailableReason | null; + noticeAcknowledged: boolean; +} + +export function useVoiceAvailability(): VoiceAvailability { + const current = useSyncExternalStore(subscribe, snapshot, snapshot); + const supported = speechRecognizer() !== null; + + useEffect(() => { + if (!supported) return; + void readNotice(); + void checkPermission(); + const subscription = AppState.addEventListener('change', (next) => { + if (next === 'active') void checkPermission(); + }); + return () => subscription.remove(); + }, [supported]); + + if (!supported) return { available: false, reason: 'unsupported', noticeAcknowledged: current.noticeAcknowledged }; + if (current.denied) return { available: false, reason: 'denied', noticeAcknowledged: current.noticeAcknowledged }; + return { available: true, reason: null, noticeAcknowledged: current.noticeAcknowledged }; +} diff --git a/src/locales/en.json b/src/locales/en.json index 85f065290..db8b4170d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2978,6 +2978,15 @@ "permissions": { "speechRecognition": "DebateOwl turns your voice into text when you dictate a question, a Crossfire answer or a Sparring speech. Apple processes the audio to recognize it.", "microphone": "DebateOwl uses the microphone while you dictate a question, a Crossfire answer or a Sparring speech." + }, + "voice": { + "recognition": "Apple's speech recognition turns your speech into text. iOS may send the audio to Apple to recognize it.", + "denied": "Voice is off because DebateOwl may not use the microphone or speech recognition. Allow both in Settings. Typing works as usual.", + "openSettings": "Open Settings", + "notAllowed": "DebateOwl may not use the microphone or speech recognition. Allow both in Settings, or type instead.", + "limit": "Today's allowance for the opponent's ElevenLabs voice is used up, so your device's voice is reading the replies.", + "deliveryDescription": "These figures come from the clock and the transcript of what you said. Nobody listened for tone, confidence or volume, and the transcript can miss words.", + "spoken": "Spoken" } } } diff --git a/src/locales/es.json b/src/locales/es.json index b332b5e51..e2a24be91 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2978,6 +2978,15 @@ "permissions": { "speechRecognition": "DebateOwl convierte tu voz en texto cuando dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring. Apple procesa el audio para reconocerlo.", "microphone": "DebateOwl usa el micrófono mientras dictas una pregunta, una respuesta de Interrogatorio o un discurso de Sparring." + }, + "voice": { + "recognition": "El reconocimiento de voz de Apple convierte tu voz en texto. iOS puede enviar el audio a Apple para reconocerlo.", + "denied": "La voz está desactivada porque DebateOwl no puede usar el micrófono ni el reconocimiento de voz. Permite ambos en Ajustes. Puedes escribir como siempre.", + "openSettings": "Abrir Ajustes", + "notAllowed": "DebateOwl no puede usar el micrófono ni el reconocimiento de voz. Permite ambos en Ajustes o escribe.", + "limit": "La cuota de hoy para la voz de ElevenLabs del oponente se agotó, así que la voz de tu dispositivo lee las respuestas.", + "deliveryDescription": "Estas cifras provienen del reloj y de la transcripción de lo que dijiste. Nadie escuchó tu tono, tu seguridad ni tu volumen, y la transcripción puede omitir palabras.", + "spoken": "Hablado" } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 3c2a24214..8acd9f22a 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2978,6 +2978,15 @@ "permissions": { "speechRecognition": "A DebateOwl szöveggé alakítja a hangodat, amikor kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz. A hangot az Apple dolgozza fel a felismeréshez.", "microphone": "A DebateOwl a mikrofont használja, amíg kérdést, keresztkérdezési választ vagy vitapárbaj-beszédet diktálsz." + }, + "voice": { + "recognition": "A beszédedet az Apple beszédfelismerése alakítja szöveggé. Ehhez az iOS elküldheti a hangot az Apple-nek.", + "denied": "A hang ki van kapcsolva, mert a DebateOwl nem használhatja a mikrofont vagy a beszédfelismerést. Engedélyezd mindkettőt a Beállításokban. Gépelni a szokott módon lehet.", + "openSettings": "Beállítások megnyitása", + "notAllowed": "A DebateOwl nem használhatja a mikrofont vagy a beszédfelismerést. Engedélyezd mindkettőt a Beállításokban, vagy gépelj.", + "limit": "Az ellenfél ElevenLabs-hangjának mai kerete elfogyott, ezért a válaszokat az eszközöd hangja olvassa fel.", + "deliveryDescription": "Ezek a számok az órából és az elhangzottak leiratából származnak. Senki nem figyelte a hangszínedet, a magabiztosságodat vagy a hangerődet, és a leiratból kimaradhatnak szavak.", + "spoken": "Szóban" } } } From 62312c135f0992da65968ede826dfb3e8fbfabe5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:19:07 +0000 Subject: [PATCH 04/11] Export a Sparring scorecard as PDF and reopen saved Crossfire rounds The scorecard's menu exports the website's Sparring PDF (set-up, totals, category scores and feedback, key moments, the transcript) through expo-print, with each spoken speech's measured delivery. Word export is left out: the docx package could not be shown to run in Hermes here. A saved Crossfire round now opens from History and the Debate tab: the debrief, the spoken delivery of a voice round and the transcript with its signals, read from the crossfires row the website saves, with an offline copy like every other read. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/crossfire-round/[id].tsx | 94 +++++++++++++++++++ apps/mobile/src/app/history.tsx | 8 +- apps/mobile/src/components/sparring-views.tsx | 18 +++- apps/mobile/src/components/work-row.tsx | 8 +- apps/mobile/src/lib/crossfire.ts | 39 +++++++- apps/mobile/src/lib/export-html.ts | 69 ++++++++++++++ apps/mobile/src/lib/offline-cache.ts | 2 + src/locales/en.json | 4 +- src/locales/es.json | 4 +- src/locales/hu.json | 4 +- 11 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/app/crossfire-round/[id].tsx diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 67547a519..f6652ee87 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -127,6 +127,7 @@ function Root() { + diff --git a/apps/mobile/src/app/crossfire-round/[id].tsx b/apps/mobile/src/app/crossfire-round/[id].tsx new file mode 100644 index 000000000..948a3aa3f --- /dev/null +++ b/apps/mobile/src/app/crossfire-round/[id].tsx @@ -0,0 +1,94 @@ +import { combineDelivery } from '@debatelab/core/voice/delivery'; +import { useLocalSearchParams } from 'expo-router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; + +import { CrossfireDebriefDocument, CrossfireTranscript } from '@/components/crossfire-views'; +import { DocumentScreen, DocumentTitle, SectionHeading } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { DocumentStatus } from '@/components/document-status'; +import { Text } from '@/components/text'; +import { DeliverySummary } from '@/components/voice'; +import { useAuth } from '@/lib/auth'; +import { backLabel } from '@/lib/back-label'; +import { fetchCrossfireRound, type SavedCrossfire } from '@/lib/crossfire'; +import { formatWhen } from '@/lib/dates'; +import { shareText } from '@/lib/export'; +import { useCopyHolder } from '@/lib/use-copy-holder'; + +type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' } | { status: 'ready'; round: SavedCrossfire }; + +/** + * A saved Crossfire round, read again: the debrief, the spoken delivery of a + * voice round, and the transcript with its live signals, as the round's + * last screen showed them. The website lists saved rounds but cannot open + * them; this reads the same `crossfires` row it saves. + */ +export default function SavedCrossfireScreen() { + const { t } = useTranslation(); + 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 }>({ owner: userId, state: { status: 'loading' } }); + const state: State = held.owner === userId ? held.state : { status: 'loading' }; + const copies = useCopyHolder(); + + useEffect(() => { + if (!userId) return; + const forUser = userId; + let active = true; + fetchCrossfireRound(forUser, id, copies.mark()) + .then((round) => { + if (active) setHeld({ owner: forUser, state: round ? { status: 'ready', round } : { status: 'missing' } }); + }) + .catch((cause: unknown) => { + console.warn('Could not load the crossfire:', cause instanceof Error ? cause.message : cause); + if (active) setHeld({ owner: forUser, state: { status: 'failed' } }); + }); + return () => { + active = false; + }; + }, [id, userId, copies]); + + const back = backLabel(t, from, 'debate'); + if (state.status !== 'ready') { + return ( + }> + + + ); + } + + const { round } = state; + const resolution = round.config?.resolution || round.title; + const delivery = combineDelivery(round.turns.flatMap((turn) => (turn.delivery ? [turn.delivery] : []))); + const transcriptText = round.turns.map((turn) => `${turn.id} · ${t(turn.kind === 'question' ? 'crossfire.question' : 'crossfire.answer')}\n${turn.text}`).join('\n\n'); + + return ( + void shareText(resolution, `${resolution}\n\n${transcriptText}`).catch(() => {})} + actions={[]} + /> + }> + + + {resolution} + + {round.debrief ? : null} + {delivery ? : null} + + + + ); +} + +const styles = StyleSheet.create({ + resolution: { fontSize: 17, lineHeight: 24, marginTop: -6 }, +}); diff --git a/apps/mobile/src/app/history.tsx b/apps/mobile/src/app/history.tsx index 49da13873..c282c62ab 100644 --- a/apps/mobile/src/app/history.tsx +++ b/apps/mobile/src/app/history.tsx @@ -80,16 +80,14 @@ const EMPTY: Record { + try { + await sharePdf(sparringExportHtml(session, t), sparringExportFileName(session), winnerText(t, session.scorecard.winner)); + } catch (cause) { + console.warn('Could not export the round:', cause instanceof Error ? cause.message : cause); + if (live.current) setFailed(t('mobile.sparring.exportFailed')); + } + }; + 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() }]} + actions={[ + // The website's PDF export of the scorecard and transcript, through the share sheet. + { key: 'pdf', label: t('mobile.sparring.exportPdf'), symbol: 'arrow.down.doc', icon: 'download', onPress: () => void exportPdf() }, + { key: 'delete', label: t('common.delete'), symbol: 'trash', icon: 'trash', destructive: true, onPress: () => void remove() }, + ]} /> } notice={failed || notice} diff --git a/apps/mobile/src/components/work-row.tsx b/apps/mobile/src/components/work-row.tsx index bf2effa8f..f96484180 100644 --- a/apps/mobile/src/components/work-row.tsx +++ b/apps/mobile/src/components/work-row.tsx @@ -27,9 +27,9 @@ export const KIND_ICONS: Record = { export type OpenedFrom = 'debate' | 'prepare' | 'practice' | 'history'; /** - * 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. + * Where a saved item opens. Cases, attacks, brainstorms, factoids, Sparring + * and Crossfire 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 { switch (item.kind) { @@ -45,6 +45,8 @@ export function workHref(item: DebateItem, from: OpenedFrom): Href { return { pathname: '/flowchart', params: { id: item.id, from } }; case 'sparring': return { pathname: '/sparring/[id]', params: { id: item.id, from } }; + case 'crossfire': + return { pathname: '/crossfire-round/[id]', params: { id: item.id, from } }; default: return '/practice'; } diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 995a9f6f0..af709295a 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -1,8 +1,10 @@ import { QUOTA_SESSION_KEY } from '@debatelab/core/ai/session-quota'; import { collectDebriefTurnIds, + CrossfireConfigSchema, CrossfireDebriefSchema, CrossfireReplySchema, + CrossfireTranscriptSchema, CrossfireTurnSignalSchema, type CrossfireAIInput, type CrossfireConfig, @@ -19,7 +21,7 @@ import { parseVoicePayload, type VoicePayload } from '@debatelab/core/voice/payl import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import { debateListScopes } from './debates'; import type { FeatureConfig } from './feature-config'; -import { afterWrite } from './offline-cache'; +import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; import { sessionSlot } from './session-memory'; import { supabase } from './supabase'; @@ -229,3 +231,38 @@ export const crossfireDraft = sessionSlot<{ state: CrossfireState; debateId: str * screen, as the website's route state: taken once, when the screen opens. */ export const crossfirePrefill = sessionSlot(() => null); + +/** A saved round as it was recorded: the transcript with its signals and delivery, and the debrief. */ +export interface SavedCrossfire { + id: string; + title: string; + createdAt: number; + config: CrossfireConfig | null; + turns: CrossfireTurn[]; + debrief: CrossfireDebrief | null; +} + +async function loadCrossfireRound(userId: string, id: string): Promise { + const { data, error, status } = await supabase.from('crossfires').select('id,title,input,output,created_at').eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw readFailed(error, status); + if (!data) return null; + const row = data as { id: string; title: string | null; input: unknown; output: unknown; created_at: string }; + const output = row.output && typeof row.output === 'object' ? (row.output as Record) : {}; + // The website's own rows, read with its schemas; a part that does not parse is left out, not guessed. + const turns = CrossfireTranscriptSchema.safeParse(output.turns); + const debrief = CrossfireDebriefSchema.safeParse(output.debrief); + const config = CrossfireConfigSchema.safeParse(row.input); + return { + id: row.id, + title: row.title ?? '', + createdAt: new Date(row.created_at).getTime(), + config: config.success ? config.data : null, + turns: turns.success ? turns.data : [], + debrief: debrief.success ? debrief.data : null, + }; +} + +/** A saved round, for reading it again from History or the Debate tab. */ +export function fetchCrossfireRound(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'crossfire', id, () => loadCrossfireRound(userId, id), mark); +} diff --git a/apps/mobile/src/lib/export-html.ts b/apps/mobile/src/lib/export-html.ts index 92a36dd74..8c678f722 100644 --- a/apps/mobile/src/lib/export-html.ts +++ b/apps/mobile/src/lib/export-html.ts @@ -3,6 +3,9 @@ import type { BrainstormOutput } from '@debatelab/core/ai/schemas/brainstorm'; import type { FactoidsOutput } from '@debatelab/core/ai/schemas/factoids'; import type { GenerateOutput } from '@debatelab/core/ai/schemas/generate'; import { mapRows, type FlowchartDocument, type FlowNodeType } from '@debatelab/core/flowchart'; +import { formatRoundTime } from '@debatelab/core/sparring/formats'; +import type { SparringSession } from '@debatelab/core/sparring/session'; +import { LONG_PAUSE_SECONDS } from '@debatelab/core/voice/delivery'; import { mapMeta, typeLabel, viaLabel } from './flowchart-text'; @@ -221,3 +224,69 @@ export function flowchartExportHtml(title: string, meta: string, doc: FlowchartD }); return page(title, meta, FLOW_STYLE + rows.join(''), t); } + +/** The website's file name (exportSparringToPdf): the day the round was last updated. */ +export function sparringExportFileName(session: SparringSession): string { + return `SparringTranscript_${new Date(session.updatedAt).toISOString().slice(0, 10)}.pdf`; +} + +const sparringSide = (side: SparringSession['userSide'], t: Translate) => (side === 'pro' ? t('export.proposition') : t('export.opposition')); + +/** + * The website's Sparring PDF (exportSparringToPdf): the round's set-up, the + * totals, every category with its feedback, the judge's verdict, key + * moments and what to work on, and the transcript. A speech the student + * spoke aloud also lists its measured delivery, as the scorecard does. + */ +export function sparringExportHtml(session: SparringSession, t: Translate): string { + const scorecard = session.scorecard; + const format = t(`sparring.format.${session.format}`, { defaultValue: session.format.toUpperCase() }); + const difficulty = t(`sparring.difficulty.${session.difficulty}`, { defaultValue: session.difficulty }); + const used = session.speeches.reduce((total, speech) => total + speech.timeUsedSeconds, 0); + const limit = session.speeches.reduce((total, speech) => total + speech.timeLimitSeconds, 0); + const winner = scorecard?.winner === 'user' ? t('export.sparringUser') : scorecard?.winner === 'ai' ? t('export.sparringOpponent') : t('export.sparringTie'); + const parts: string[] = [TABLE_STYLE]; + parts.push(`

${escape(t('export.sparringConfiguration'))}

`); + parts.push(paragraph(t('export.motion'), session.motion)); + parts.push(paragraph(t('export.sparringFormat'), format)); + parts.push(paragraph(t('export.side'), sparringSide(session.userSide, t))); + parts.push(paragraph(t('export.sparringDifficulty'), difficulty)); + + parts.push(`

${escape(t('export.sparringTotals'))}

`); + parts.push(scorecard + ? table( + [t('export.sparringUser'), t('export.sparringScore'), t('export.sparringOpponent'), t('export.sparringScore'), t('export.sparringResult')], + [[t('export.sparringUser'), `${scorecard.userScore}/100`, t('export.sparringOpponent'), `${scorecard.aiScore}/100`, `${t('export.sparringWinner')}: ${winner}`]], + ) + : `

${escape(t('export.sparringScorecardUnavailable'))}

`); + parts.push(paragraph(t('export.sparringSpeeches'), String(session.speeches.length))); + parts.push(paragraph(t('export.sparringTimeUsed'), `${formatRoundTime(used)} / ${formatRoundTime(limit)}`)); + + if (scorecard) { + parts.push(`

${escape(t('export.sparringCategories'))}

`); + parts.push(table( + [t('export.sparringCategory'), t('export.sparringUser'), t('export.sparringOpponent'), t('export.sparringFeedback')], + scorecard.categories.map((category) => [t(`sparring.category.${category.name}`, { defaultValue: category.name }), `${category.userScore}/25`, `${category.aiScore}/25`, category.feedback]), + )); + parts.push(`

${escape(t('export.sparringFeedback'))}

${escape(scorecard.verdict)}

${escape(scorecard.overallFeedback)}

`); + if (scorecard.keyMoments.length) parts.push(`

${escape(t('export.sparringKeyMoments'))}

${bullets(scorecard.keyMoments)}`); + if (scorecard.improvementAreas.length) parts.push(`

${escape(t('export.sparringImprovementAreas'))}

${bullets(scorecard.improvementAreas)}`); + } + + parts.push(`

${escape(t('export.sparringTranscript'))}

`); + if (session.speeches.length === 0) parts.push(`

${escape(t('export.sparringNoTranscript'))}

`); + session.speeches.forEach((speech, index) => { + const delivery = speech.delivery + ? paragraph(t('voiceRound.delivery.title'), [ + `${t('voiceRound.delivery.pace')}: ${t('voiceRound.delivery.wordsPerMinute', { count: speech.delivery.wordsPerMinute })}`, + `${t('voiceRound.delivery.fillers')}: ${speech.delivery.fillerCount}`, + `${t('voiceRound.delivery.longPauses', { seconds: LONG_PAUSE_SECONDS })}: ${speech.delivery.longPauses}`, + ].join(' · ')) + : ''; + parts.push(`

${index + 1}.${escape(t(`sparring.turns.${speech.turnId}`, { defaultValue: speech.role }))}

${ + paragraph(t('export.side'), sparringSide(speech.side, t))}${ + paragraph(t('export.sparringTimeUsed'), `${formatRoundTime(speech.timeUsedSeconds)} / ${formatRoundTime(speech.timeLimitSeconds)}`)}${ + delivery}${speech.content.split(/\n{2,}/).map((block) => `

${escape(block)}

`).join('')}
`); + }); + return page(t('export.sparringTranscript'), `${sparringSide(session.userSide, t)} | ${format} | ${difficulty}`, parts.join(''), t); +} diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 58dd6dea9..1614478fe 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -39,6 +39,8 @@ const LIMITS = { /** Judged Sparring rounds per debate, and each saved round. */ 'sparring-rounds': 20, sparring: 40, + /** Saved Crossfire rounds, each opened on its own. */ + crossfire: 20, /** History's six lists of a debate, and the work outside every debate (`one-off`). */ history: 20, } as const; diff --git a/src/locales/en.json b/src/locales/en.json index db8b4170d..1cfe5b740 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2883,7 +2883,9 @@ "resume": "Resume", "replay": "Replay", "versus": "{{user}} vs {{opponent}}", - "roundsFailed": "Could not load your sparring rounds." + "roundsFailed": "Could not load your sparring rounds.", + "exportPdf": "Export as PDF", + "exportFailed": "The PDF could not be made. Try again." }, "brainstorm": { "strengths": { diff --git a/src/locales/es.json b/src/locales/es.json index e2a24be91..c5698d792 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2883,7 +2883,9 @@ "resume": "Reanudar", "replay": "Repasar", "versus": "{{user}} vs. {{opponent}}", - "roundsFailed": "No se pudieron cargar tus rondas de sparring." + "roundsFailed": "No se pudieron cargar tus rondas de sparring.", + "exportPdf": "Exportar como PDF", + "exportFailed": "No se pudo crear el PDF. Inténtalo de nuevo." }, "brainstorm": { "strengths": { diff --git a/src/locales/hu.json b/src/locales/hu.json index 8acd9f22a..4bf5499cf 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2883,7 +2883,9 @@ "resume": "Folytatás", "replay": "Visszanézés", "versus": "{{user}} – {{opponent}}", - "roundsFailed": "Nem sikerült betölteni a vitapárbaj-fordulóidat." + "roundsFailed": "Nem sikerült betölteni a vitapárbaj-fordulóidat.", + "exportPdf": "Exportálás PDF-be", + "exportFailed": "Nem sikerült elkészíteni a PDF-et. Próbáld újra." }, "brainstorm": { "strengths": { From 334f9899010fc8cfcbfe4ad28e75b34be4b3e6b8 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:24:24 +0000 Subject: [PATCH 05/11] Add the speech time calculator and motion generator to Practice Both are the website's free tools, from the same core logic and the website's tools catalog: the calculator counts a speech's words, times it at four paces and shows which slots of the seven formats it fits; the generator filters the practice motion bank by topic, level and type and draws one motion or three. A drawn motion can be copied, shared or opened as a new debate. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 102 ++++++++++- .../src/app/(tabs)/(practice)/practice.tsx | 6 +- .../src/app/(tabs)/(practice)/soon/[tool].tsx | 2 +- apps/mobile/src/app/_layout.tsx | 2 + apps/mobile/src/app/motions.tsx | 172 ++++++++++++++++++ apps/mobile/src/app/new-debate.tsx | 10 +- apps/mobile/src/app/speech-time.tsx | 159 ++++++++++++++++ apps/mobile/src/components/practice-tools.ts | 10 +- src/locales/en.json | 7 + src/locales/es.json | 7 + src/locales/hu.json | 7 + 11 files changed, 476 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/app/motions.tsx create mode 100644 apps/mobile/src/app/speech-time.tsx diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index a9442aa83..bff498dc6 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -13,7 +13,7 @@ * SCREENS=you,timer limits the signed-in screens to those groups: debate, * debate-check, library, prepare, practice, you, empty, ask, results, brainstorm, * factoids, case-edits, context, limits, - * timer, voice, crossfire, sparring, flowchart, one-off, history, announcements, + * timer, voice, tools, crossfire, sparring, flowchart, one-off, history, announcements, * account, model. Sign-in screens are always shot. * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * @@ -1638,6 +1638,18 @@ async function shootVoiceScreens(context: BrowserContext, prefix: string, langua await page.getByTestId('spoken-delivery').scrollIntoViewIfNeeded(); await page.waitForTimeout(400); await shoot(page, `${prefix}-voice-scorecard`); + // The scorecard's PDF (export.web.ts keeps the HTML expo-print would print), rendered as a page of its own. + await page.getByTestId('document-more').click(); + await page.getByRole('menuitem').first().click(); + const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string } }).__debatelabExport ?? {}); + if (!exported.html || !exported.fileName?.startsWith('SparringTranscript_')) problems.push(`${prefix} voice: the scorecard PDF was ${exported.fileName ?? 'not made'}`); + else { + const pdf = await context.newPage(); + await pdf.setViewportSize({ width: 794, height: 1123 }); + await pdf.setContent(exported.html); + await pdf.screenshot({ path: join(OUT, `${prefix}-voice-sparring-pdf-html.png`), fullPage: true }); + await pdf.close(); + } const facts = (await page.getByTestId('delivery-facts').first().textContent()) ?? ''; if (!/\d/.test(facts) || !facts.includes(lang === 'hu' ? 'hát' : 'um')) problems.push(`${prefix} voice: the delivery facts read "${facts}"`); const spoken = await page.evaluate(() => (window as unknown as { __debatelabOpponentVoice?: { segments: number }[] }).__debatelabOpponentVoice ?? []); @@ -1700,6 +1712,90 @@ async function shootVoiceScreens(context: BrowserContext, prefix: string, langua await generate.close(); } +// A saved Crossfire round, as CrossfirePage and the app save it, for the read-only view. +const SAVED_CROSSFIRE_ID = '00000000-0000-4000-8000-00000000cf02'; + +/** + * The free tools in Practice: the speech time calculator with a speech in + * it, the motion generator after drawing three, a drawn motion opened as a + * new debate (shown, never saved), and a saved Crossfire round read again + * from a fixed row. + */ +async function shootPracticeTools(context: BrowserContext, prefix: string, language: string, problems: string[]) { + const lang = language === 'hu' ? 'hu' : 'en'; + const page = await context.newPage(); + watchErrors(page, problems, `${prefix} practice tools`); + await pinSettings(page, language); + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-speech-time').click({ timeout: 30_000 }); + await page.getByTestId('speech-time-text').fill([...SPARRING_TEXT[lang].speeches, ...SPARRING_TEXT[lang].opponent].join('\n\n')); + await page.getByTestId('slot-fits').waitFor(); + const words = Number(await page.getByTestId('word-count').textContent()); + if (!(words > 100)) problems.push(`${prefix} speech time: counted ${words} words`); + await settle(page); + await shoot(page, `${prefix}-tools-speech-time`); + await page.getByTestId('slot-fits').scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + await shoot(page, `${prefix}-tools-speech-fits`); + + await page.goto(`${ORIGIN}/practice`); + await page.getByTestId('practice-tool-motions').click({ timeout: 30_000 }); + await page.getByTestId('motion-filter-education').click(); + await page.getByTestId('motions-draw-three').click(); + await page.getByTestId('motion-card').nth(2).waitFor(); + await settle(page); + await shoot(page, `${prefix}-tools-motions`); + const drawn = (await page.getByTestId('motion-card').first().textContent()) ?? ''; + await page.getByTestId('motion-card').first().getByRole('button').last().click(); + await page.getByTestId('new-debate-sheet').waitFor({ timeout: 15_000 }); + const prefilled = await page.locator('[data-testid="new-debate-sheet"] textarea, [data-testid="new-debate-sheet"] input').first().inputValue(); + if (!prefilled || !drawn.includes(prefilled)) problems.push(`${prefix} motions: the new debate opened with "${prefilled}" for "${drawn}"`); + await settle(page); + await shoot(page, `${prefix}-tools-motion-debate`); + await page.close(); + + const saved = await context.newPage(); + watchErrors(saved, problems, `${prefix} saved crossfire`); + await pinSettings(saved, language); + const text = CROSSFIRE_TEXT[lang]; + const turns = [ + { id: 'T1', sequence: 1, speaker: 'user', kind: 'question', text: text.questions[0], createdAt: 1, delivery: { speakingSeconds: 6, wordCount: 9, wordsPerMinute: 90, fillerCount: 1, fillers: [{ word: lang === 'hu' ? 'hát' : 'um', count: 1 }], longPauses: 0, longestPauseSeconds: 0 } }, + { id: 'T2', sequence: 2, speaker: 'ai', kind: 'answer', text: text.answers[0], createdAt: 2 }, + { id: 'T3', sequence: 3, speaker: 'user', kind: 'question', text: text.questions[1], createdAt: 3 }, + { id: 'T4', sequence: 4, speaker: 'ai', kind: 'answer', text: text.answers[1], createdAt: 4 }, + ]; + const d = text.debrief; + await saved.route(`${REST}/crossfires*`, (route) => route.fulfill({ + status: 200, + headers: { 'access-control-allow-origin': '*' }, + json: { + id: SAVED_CROSSFIRE_ID, + title: 'This House would ban private cars from city centres', + input: { resolution: 'This House would ban private cars from city centres', argument: text.argument, mode: 'question-ai', style: 'cooperative', durationSeconds: 300, seedQuestions: [], useSourceLibrary: false }, + output: { + turns, + debrief: { + concessionsSecured: [{ turnIds: ['T4'], claim: d.concession }], + controlledQuestions: [{ turnIds: ['T1'], claim: d.controlled }], + weakOrRepetitiveQuestions: [], + evasiveOrIncompleteAnswers: [], + missedFollowUps: [{ turnIds: ['T2'], claim: d.missed }], + betterFollowUpQuestions: [{ turnIds: ['T2'], question: d.better, reason: d.reason }], + strongestExchange: { turnIds: ['T3', 'T4'], claim: d.strongest }, + }, + endedReason: 'manual', + }, + created_at: new Date().toISOString(), + }, + })); + await saved.goto(`${ORIGIN}/crossfire-round/${SAVED_CROSSFIRE_ID}`); + await saved.getByTestId('crossfire-saved').waitFor({ timeout: 30_000 }); + await saved.getByTestId('spoken-delivery').waitFor(); + await settle(saved); + await shoot(saved, `${prefix}-tools-saved-crossfire`); + await saved.close(); +} + /** * The Timer from the Practice tab, on the website's free timer: idle, * running, and over time on the school format; a British Parliamentary @@ -2397,6 +2493,10 @@ async function main() { await shootLimitSheets(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'limits')); shots += 2; } + if (want('tools')) { + await shootPracticeTools(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'tools')); + shots += 6; + } if (want('voice')) { await shootVoiceScreens(signedIn, prefix, language, problems).catch(failedIn(signedIn, prefix, 'voice')); shots += 6; diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index b5f18de0b..d38984537 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -25,6 +25,8 @@ const TOOL_ROUTES: Record = { crossfire: { pathname: '/crossfire', params: { from: 'practice' } }, sparring: { pathname: '/sparring', params: { from: 'practice' } }, timer: '/timer', + 'speech-time': { pathname: '/speech-time', params: { from: 'practice' } }, + motions: { pathname: '/motions', params: { from: 'practice' } }, }; /** @@ -90,10 +92,10 @@ export default function PracticeScreen() { - {t(`nav.${tool.key}`)} + {'title' in tool ? t(tool.title, { ns: 'tools' }) : t(`nav.${tool.key}`)} - {t(`navDesc.${tool.key}`)} + {'description' in tool ? t(tool.description) : t(`navDesc.${tool.key}`)} diff --git a/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx index 4318cbcd9..c6056648e 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/soon/[tool].tsx @@ -9,5 +9,5 @@ export default function PracticeToolScreen() { const { t } = useTranslation(); const { tool } = useLocalSearchParams<{ tool: string }>(); const entry = PRACTICE_TOOLS.find((item) => item.key === tool); - return ; + return ; } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f6652ee87..d0c6786af 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -126,6 +126,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/motions.tsx b/apps/mobile/src/app/motions.tsx new file mode 100644 index 000000000..3b40b1455 --- /dev/null +++ b/apps/mobile/src/app/motions.tsx @@ -0,0 +1,172 @@ +import { drawMotions, filterMotions, motionText, NO_FILTERS, type MotionFilters, type MotionWording } from '@debatelab/core/free-tools/motion-bank'; +import { MOTION_DIFFICULTIES, MOTION_TOPICS, MOTION_TYPES, type PracticeMotion } from '@debatelab/core/free-tools/motion-types'; +import { PRACTICE_MOTIONS } from '@debatelab/core/free-tools/practice-motions'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { PrimaryButton, SecondaryButton, TextButton } from '@/components/buttons'; +import { Chip } from '@/components/chip'; +import { DocumentScreen, DocumentTitle } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { EmptyState } from '@/components/empty-state'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { backLabel } from '@/lib/back-label'; +import { copyText, shareText } from '@/lib/export'; +import { haptics } from '@/lib/haptics'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +const toggle = (list: T[], value: T) => (list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]); + +/** + * The website's motion generator (src/components/free-tools/MotionGenerator.tsx), + * from the same motion bank: filter by topic, difficulty and type, pick the + * wording, and draw one motion or three for a veto drill. A drawn motion can + * be copied, shared, or made a new debate, as the website's "prep both sides" + * link does. + */ +export default function MotionsScreen() { + const { t } = useTranslation(); + const tools = (key: string, options?: Record) => t(key, { ns: 'tools', ...options }); + const router = useRouter(); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { colors } = useTheme(); + const [filters, setFilters] = useState(NO_FILTERS); + const [wording, setWording] = useState('house'); + const [shown, setShown] = useState([]); + const [copied, setCopied] = useState(null); + + const pool = filterMotions(PRACTICE_MOTIONS, filters); + const filtered = filters.topics.length + filters.difficulties.length + filters.types.length > 0; + const draw = (count: number) => { + haptics.success(); + setCopied(null); + setShown((current) => drawMotions(pool, count, Math.random, current.map((motion) => motion.id))); + }; + + const chips = (key: K, values: readonly MotionFilters[K][number][], label: (value: MotionFilters[K][number]) => string) => ( + + {values.map((value) => ( + setFilters((current) => ({ ...current, [key]: toggle(current[key] as string[], value) }))} + testID={`motion-filter-${value}`} + /> + ))} + + ); + + return ( + } + actionWidths={[1, 1.3]} + actions={ + <> + draw(3)} disabled={pool.length < 3} testID="motions-draw-three" /> + draw(1)} disabled={pool.length === 0} testID="motions-draw" /> + + }> + + + {shown.length === 0 ? ( + + ) : ( + + {shown.map((motion, index) => { + const text = motionText(motion, wording); + return ( + + {shown.length > 1 ? ( + + {tools('motions.optionN', { n: index + 1 })} + + ) : null} + + {text} + + {motion.infoSlide ? ( + + {`${tools('motions.infoSlide')} `} + {motion.infoSlide} + + ) : null} + + {[tools(`motions.topics.${motion.topic}`), tools(`motions.difficulties.${motion.difficulty}`), tools(`motions.types.${motion.type}`)].join(', ')} + + + { + void copyText(text).then(() => { + haptics.success(); + setCopied(motion.id); + }); + }} + /> + void shareText(text, text).catch(() => {})} /> + + router.push({ pathname: '/new-debate', params: { motion: text } })} /> + + ); + })} + + )} + + + + {tools('motions.wording')} + + + value={wording} + onChange={setWording} + options={[{ value: 'house', label: tools('motions.wordingHouse') }, { value: 'resolved', label: tools('motions.wordingResolved') }]} + /> + + + + {tools('motions.topic')} + + {chips('topics', MOTION_TOPICS, (topic) => tools(`motions.topics.${topic}`))} + + + + {tools('motions.difficulty')} + + {chips('difficulties', MOTION_DIFFICULTIES, (level) => tools(`motions.difficulties.${level}`))} + + + + {tools('motions.type')} + + {chips('types', MOTION_TYPES, (type) => tools(`motions.types.${type}`))} + + + + {tools('motions.poolSize', { count: pool.length })} + + {filtered ? setFilters(NO_FILTERS)} /> : null} + + + ); +} + +const styles = StyleSheet.create({ + cards: { gap: 12 }, + card: { borderWidth: 2, borderRadius: radius.card, padding: 16, gap: 8 }, + option: { fontSize: 13.5 }, + motion: { fontSize: 21, lineHeight: 28 }, + info: { fontSize: 14.5, lineHeight: 20 }, + meta: { fontSize: 13.5 }, + cardActions: { flexDirection: 'row', gap: 18 }, + group: { gap: 8 }, + groupLabel: { fontSize: 14, marginLeft: 4 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + poolRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + pool: { fontSize: 14.5 }, +}); diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index d036183f2..ac9ac6055 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -1,4 +1,4 @@ -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; @@ -8,11 +8,16 @@ import { SheetScrollView } from '@/components/sheet'; import { Text } from '@/components/text'; import { useDebates } from '@/lib/debate-context'; -/** Start a debate: the website's Start new debate dialog (motion, format, role, instructions) plus the judge. It becomes current. */ +/** + * Start a debate: the website's Start new debate dialog (motion, format, role, + * instructions) plus the judge. It becomes current. The motion generator + * opens it with its motion filled in. + */ export default function NewDebateSheet() { const { t } = useTranslation(); const router = useRouter(); const { create } = useDebates(); + const { motion } = useLocalSearchParams<{ motion?: string }>(); return ( @@ -26,6 +31,7 @@ export default function NewDebateSheet() { {t('debate.startNewDesc')} { await create(values); diff --git a/apps/mobile/src/app/speech-time.tsx b/apps/mobile/src/app/speech-time.tsx new file mode 100644 index 000000000..0a2743c52 --- /dev/null +++ b/apps/mobile/src/app/speech-time.tsx @@ -0,0 +1,159 @@ +import { BUILT_IN_FORMATS, formatDuration } from '@debatelab/core/free-tools/formats'; +import { countWords, MAX_WPM, MIN_WPM, PACES, slotFits, spokenSeconds } from '@debatelab/core/free-tools/speech-time'; +import { useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, TextInput, View } from 'react-native'; + +import { DocumentScreen, DocumentTitle, SectionHeading } from '@/components/document'; +import { DocumentHeader } from '@/components/document-header'; +import { Field } from '@/components/field'; +import { Icon } from '@/components/icon'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { backLabel } from '@/lib/back-label'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +type PaceId = (typeof PACES)[number]['id'] | 'custom'; + +/** + * The website's speech time calculator (src/components/free-tools/ + * SpeechTimeCalculator.tsx), from the same core logic: the words in a + * speech, how long it runs at a slow, average, fast or your own pace, and + * which speech slots of the seven formats it fits. Nothing leaves the device. + */ +export default function SpeechTimeScreen() { + const { t } = useTranslation(); + const tools = (key: string, options?: Record) => t(key, { ns: 'tools', ...options }); + const { from } = useLocalSearchParams<{ from?: string }>(); + const { colors } = useTheme(); + const [text, setText] = useState(''); + const [customText, setCustomText] = useState('140'); + const [fitPace, setFitPace] = useState('average'); + + const words = countWords(text); + const custom = Number(customText); + const wpm = Math.min(MAX_WPM, Math.max(MIN_WPM, Number.isFinite(custom) && custom > 0 ? custom : MIN_WPM)); + const paces: { id: PaceId; wpm: number }[] = [...PACES.map((pace) => ({ id: pace.id as PaceId, wpm: pace.wpm as number })), { id: 'custom', wpm }]; + const fitWpm = paces.find((pace) => pace.id === fitPace)?.wpm ?? 130; + const fits = words > 0 ? slotFits(words, fitWpm, BUILT_IN_FORMATS) : []; + + return ( + }> + + + + {t('mobile.practice.calcPrivacy')} + + + + + {words} + + {tools('calc.words', { count: words })} + + + + + {paces.map((pace) => ( + + + {tools(`calc.pace.${pace.id}`)} + + + {formatDuration(spokenSeconds(words, pace.wpm))} + + {pace.id === 'custom' ? ( + + setCustomText(value.replace(/[^0-9]/g, '').slice(0, 3))} + keyboardType="number-pad" + accessibilityLabel={tools('calc.customWpm')} + style={[styles.customInput, { color: colors.foreground, borderColor: colors.inputBorder, backgroundColor: colors.input }]} + testID="custom-wpm" + /> + + {tools('calc.wpm')} + + + ) : ( + + {`${pace.wpm} ${tools('calc.wpm')}`} + + )} + + ))} + + + {tools('calc.approximate')} + + + + + value={fitPace} + onChange={setFitPace} + options={paces.map((pace) => ({ value: pace.id, label: tools(`calc.pace.${pace.id}`) }))} + /> + {fits.length === 0 ? ( + + {tools('calc.fitsEmpty')} + + ) : ( + + {fits.map((fit, index) => ( + 0 && { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.line }]}> + + + {`${fit.formatShortName} ${formatDuration(fit.slotSeconds)}`} + + + {fit.roles} + + + + {fit.fits ? : null} + + {fit.fits ? tools('calc.spare', { time: formatDuration(fit.spareSeconds) }) : tools('calc.over', { time: formatDuration(-fit.spareSeconds) })} + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + speech: { minHeight: 180, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top', fontFamily: fontFamily.regular }, + note: { fontSize: 13.5, lineHeight: 19, marginTop: -6 }, + body: { fontSize: 15, lineHeight: 21 }, + count: { borderWidth: 2, borderRadius: radius.card, paddingVertical: 14, alignItems: 'center' }, + countValue: { fontSize: 40, lineHeight: 46, fontVariant: ['tabular-nums'] }, + countLabel: { fontSize: 15 }, + paces: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + pace: { flexGrow: 1, flexBasis: '45%', borderWidth: 2, borderRadius: radius.card, padding: 14, gap: 2 }, + paceLabel: { fontSize: 14 }, + paceTime: { fontSize: 26, lineHeight: 32, fontVariant: ['tabular-nums'] }, + paceMeta: { fontSize: 13 }, + customRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + customInput: { width: 58, height: 32, borderWidth: 1.5, borderRadius: 8, paddingHorizontal: 8, fontSize: 15, fontFamily: fontFamily.regular }, + fits: { borderWidth: 2, borderRadius: radius.card, overflow: 'hidden' }, + fit: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 14, paddingVertical: 10 }, + fitText: { flex: 1, gap: 1 }, + fitSlot: { fontSize: 15.5 }, + fitRoles: { fontSize: 13 }, + fitResult: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + fitValue: { fontSize: 14.5, fontVariant: ['tabular-nums'] }, +}); diff --git a/apps/mobile/src/components/practice-tools.ts b/apps/mobile/src/components/practice-tools.ts index d8c07b07f..7c809a240 100644 --- a/apps/mobile/src/components/practice-tools.ts +++ b/apps/mobile/src/components/practice-tools.ts @@ -1,12 +1,18 @@ import type { IconName } from './icon-paths'; -/** The Practice tools, in the website's order. All of them are in the app. */ +/** + * The Practice tools, in the website's order, then two of its free tools. + * All of them are in the app. The free tools name themselves from the + * website's `tools` catalog. + */ export const PRACTICE_TOOLS = [ { key: 'attack', icon: 'shield' }, { key: 'ask', icon: 'chat' }, { key: 'crossfire', icon: 'help' }, { key: 'sparring', icon: 'swords' }, { key: 'timer', icon: 'timer' }, -] as const satisfies readonly { key: string; icon: IconName }[]; + { key: 'speech-time', icon: 'quote', title: 'calc.region', description: 'mobile.practice.speechTimeDesc' }, + { key: 'motions', icon: 'sparkles', title: 'motions.region', description: 'mobile.practice.motionsDesc' }, +] as const satisfies readonly { key: string; icon: IconName; title?: string; description?: string }[]; export type PracticeTool = (typeof PRACTICE_TOOLS)[number]['key']; diff --git a/src/locales/en.json b/src/locales/en.json index 1cfe5b740..c7ca21528 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2989,6 +2989,13 @@ "limit": "Today's allowance for the opponent's ElevenLabs voice is used up, so your device's voice is reading the replies.", "deliveryDescription": "These figures come from the clock and the transcript of what you said. Nobody listened for tone, confidence or volume, and the transcript can miss words.", "spoken": "Spoken" + }, + "practice": { + "speechTimeDesc": "How long a speech runs", + "speechTimeIntro": "Paste or type a speech to see how long it takes to deliver.", + "calcPrivacy": "Counted on this device. The text is never uploaded or stored.", + "motionsDesc": "Draw a practice motion to argue", + "motionsIntro": "Original practice motions, by topic, level and type." } } } diff --git a/src/locales/es.json b/src/locales/es.json index c5698d792..ef2b0f4b2 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2989,6 +2989,13 @@ "limit": "La cuota de hoy para la voz de ElevenLabs del oponente se agotó, así que la voz de tu dispositivo lee las respuestas.", "deliveryDescription": "Estas cifras provienen del reloj y de la transcripción de lo que dijiste. Nadie escuchó tu tono, tu seguridad ni tu volumen, y la transcripción puede omitir palabras.", "spoken": "Hablado" + }, + "practice": { + "speechTimeDesc": "Cuánto dura un discurso", + "speechTimeIntro": "Pega o escribe un discurso para ver cuánto tarda en pronunciarse.", + "calcPrivacy": "Se cuenta en este dispositivo. El texto nunca se sube ni se guarda.", + "motionsDesc": "Saca una moción de práctica", + "motionsIntro": "Mociones de práctica originales, por tema, nivel y tipo." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 4bf5499cf..c30494c62 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2989,6 +2989,13 @@ "limit": "Az ellenfél ElevenLabs-hangjának mai kerete elfogyott, ezért a válaszokat az eszközöd hangja olvassa fel.", "deliveryDescription": "Ezek a számok az órából és az elhangzottak leiratából származnak. Senki nem figyelte a hangszínedet, a magabiztosságodat vagy a hangerődet, és a leiratból kimaradhatnak szavak.", "spoken": "Szóban" + }, + "practice": { + "speechTimeDesc": "Mennyi ideig tart egy beszéd", + "speechTimeIntro": "Illeszd be vagy írd be a beszédet, és megtudod, mennyi ideig tart elmondani.", + "calcPrivacy": "Ezen az eszközön számoljuk. A szöveget nem töltjük fel és nem tároljuk.", + "motionsDesc": "Gyakorló vitatétel húzása", + "motionsIntro": "Saját gyakorló vitatételek téma, szint és típus szerint." } } } From f9ff12c959ffdd5eb8eaeb2dc1422e713f4b49bc Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:25:14 +0000 Subject: [PATCH 06/11] Describe round 3's timer, voice and practice tools in the app guide Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 12 +++++++----- docs/development.md | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 24bfe383e..a08a25b21 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -18,7 +18,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb ## 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`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); 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), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, 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/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`, `delete-data.tsx`, `add-link.tsx`, `new-note.tsx`, `add-file.tsx` (Import file), `source/[id].tsx`, `edit-debate.tsx` (Edit settings), `debate-check.tsx` (the five-day check), `limit.tsx` (the website's limit-reached and model-restricted dialogs), `save-case.tsx` (Save case with a title) and `context-review.tsx` (the debate context Review); 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`, `crossfire-round/[id].tsx` (a saved round, read only), `timer.tsx` (a full-screen modal) with its `timer-format.tsx` sheet (a custom format), `speech-time.tsx` and `motions.tsx` (the website's speech time calculator and motion generator), `flowchart/` (the screen `index.tsx` with its `card`, `claim` and `maps` form sheets), and `history.tsx`, 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`), `native-capabilities.ts` (what the running binary has beyond Expo Go), 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 source actions (`addSource` with Undo's restore, `updateSource`, `removeSource`, `toggleSource` and `setFolderActive` as `setSourcesActive`, the folder actions and `clearLibrary`) and `src/lib/source-import.ts`. Switch changes run in one serial queue, so they reach the server in the order the student made them; a screen shows a change at once and puts it back when the write fails (`source-actions.ts`, with the website's messages). The Library always shows the Current Debate, also in one-off mode, as on the website. Its folder list, search and eight sort orders are the website's, in `packages/core/src/library.ts`. Scan pages is not in the app yet. @@ -33,9 +33,11 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - Ask's Stop keeps the answer so far, marked as stopped. Its General and Clarify modes go in the request; Clarify and the options `ai-stream` refuses stream live from the `ai` function through `postAI`. An answer saves to the Current Debate's `Ask` folder. Attachments (`ask-attachments.ts`, expo-document-picker, in Expo Go) are read on the phone (`read-file.ts`) and sent in the website's format (`packages/core/src/ai/ask-files.ts`). - A case's and an attack's next steps are `result-links.ts`: "Open in Crossfire" fills the next Crossfire form through the one-shot `crossfirePrefill` slot (the website's route state; the builders are `packages/core/src/crossfire/prefill.ts`), and a case is built into an unsaved map with `flowchartFromCase` (`packages/core/src/flowchart.ts`, shared with the website), only in the debate the Flowchart is on, as on the website. - Limits mirror the website's subscription dialogs: `refusal.ts` turns a 402 or 403 refusal (with the limit details `errorFrom` keeps) into the `limit` sheet's parameters and holds the website's messages (`limit-message.ts`); the tools open the sheet instead of an inline error, and Ask, Crossfire and Sparring open it beside theirs. Upgrading waits for in-app purchase, so the sheet's next step is the plan in You. `usage.ts` reads today's uses for the line under each tool's title (`DailyUsageLine`, the website's DailyUsage) and reads again after every AI request (`onAIRequestSettled`). `session-quota.ts` asks what a Crossfire or Sparring round costs (`quotaCheck`), shows it above Start (`SessionCostNote`) and opens the sheet before a round the day cannot cover. -- 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), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does 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. +- Timer mirrors the website's free round timer (`src/components/free-tools/DebateTimer.tsx`), not the in-app TimerPage: the seven formats with their speeches, points-of-information windows, prep banks, overtime and grace come from `packages/core/src/free-tools/` (moved there from `src/lib/free-tools/`, which re-exports them; the custom format's URL codec stays in the website). `timer.ts` holds the session (format choice, round, mute), its stored form and the Live Activity's times (`timer.test.ts`); `timer-store.ts` keeps it and the custom formats saved on the device in AsyncStorage, so a running speech survives closing the timer and restarting the app. `components/timer-runner.tsx`, in the root layout, sounds the cues (a knock when POIs open or close, a beep at one minute left, two at time; after a gap in the background only time up) and stops an exhausted prep bank, with signals mounted only while a clock runs, and keeps the Live Activity in step. `timer-signals.ts` plays the bundled sounds in `assets/sounds` (made by `scripts/render-timer-sounds.ts`) with expo-audio, taps with expo-haptics and keeps the screen awake while the timer screen shows a running clock; `timer-signals.web.ts` only records the signals for the screenshot script. +- The Lock Screen timer: `src/widgets/timer-widgets.tsx` holds the Live Activity (`DebateTimerActivity`: Lock Screen banner and Dynamic Island) and the lock-screen widget (`DebateTimerWidget`, the `widgets[]` entry in `app.config.ts`), drawn by iOS from SwiftUI timer texts so they count without the app. Their functions run in the extension's own runtime ('widget' directive): no imports or outside names, every string in the props. `live-activity.ts` loads them only when `nativeCapabilities().liveActivity` (a development build with `DEBATELAB_APP_GROUP`), takes over or ends an activity left by an earlier launch, and ends the activity when no clock has time on it; `live-activity.web.ts` records the state for the screenshot script. Round 4's Home Screen widget adds its own entry to the same `widgets[]` array. +- 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), with the round's id as `quotaSession` on turns and the debrief so the round is charged once, as the website does 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. A saved round opens read only from History and the Debate tab (`crossfire-round/[id].tsx`, `fetchCrossfireRound`, with an offline copy): debrief, spoken delivery and transcript. The website lists saved rounds but cannot open them. +- 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 and exports the website's Sparring PDF (`sparringExportHtml`; Word export is not in the app). +- Voice rounds mirror the website's (`src/hooks/use-speech-capture.ts`, `src/lib/voice/`): `speech-recognition.ts` reaches `expo-speech-recognition` only in a build that has it (never in Expo Go); `speech-capture.ts` is the capture state machine (restart when iOS ends a request, three quick failures end it, a stop waits up to 1.5 s for the last words, long pauses timed; `speech-capture.test.ts`), and `use-speech-capture.ts` its hook. Words fill the draft, and the turn's delivery is measured with `packages/core/src/voice/delivery.ts` on submit. Turns send `voice: true` beside `input` and read the signed `voice` segments (`parseVoicePayload`); `opponent-voice.ts` plays them from the `voice` function with expo-audio and falls back to expo-speech, and `voice-availability.ts` knows when voice cannot run and remembers the first-use notice. `components/voice.tsx` has the switch with the notice (the website's wording, with Apple's recognizer in place of the browser), the microphone control, the opponent bar, delivery facts and the dictation button that Generate's motion and Ask's question use. In Expo Go the switch stays off with the note and dictation does not show. The `.web.ts` versions fake the recognizer from `window.__debatelabFakeSpeech` and only record the opponent's replies, for the screenshot script. - 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`). - History mirrors `src/pages/HistoryPage.tsx` and `UnsortedWork.tsx`: `history.ts` reads each table for one debate, or every kind plus Library sources outside all debates, and adds unsorted work to a debate through the website's `attach_to_debate` function. Chats reopen in Ask (`/ask?chat=`). A Sparring round opens `sparring/[id]` and swipes to delete as a case does (`deleteSparringRound`); an unsorted round is added to a debate with the side the student spoke, through `attachToDebate`, which also checks the signed-in account. - Announcements mirror the website's banner (`AppLayout.tsx`, `AnnouncementBanner.tsx`) at the top of the Debate tab: `announcements.ts` lists and dismisses through the `announcements` Edge Function, and `use-announcements.ts` keeps the website's startsAt/expiresAt filter (shared in `packages/core/src/announcements.ts`), its one-minute reload guard, and reloads on focus, foreground and account change. Dismissals are stored on the server, so they are shared with the website. @@ -63,4 +65,4 @@ ONLY=offline PORT=18080 STAGING_DEMO_CODE=... npx tsx apps/mobile/scripts/screen Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). -knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app reaches by native module name, not by import, and `@expo/dom-webview`, which Expo's DOM components load by name. +knip ignores `expo-updates`: its Expo plugin expects the package whenever the app config does not disable updates, and EAS Update is not set up yet. It also ignores `expo-speech-recognition` and `expo-widgets`, which the app loads with `require` only in a build that has them, and `@expo/dom-webview`, which Expo's DOM components load by name. diff --git a/docs/development.md b/docs/development.md index 107e0bfc6..c9f092d4c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -137,13 +137,13 @@ Stop it with `$PREVIEW down "debatelab mobile expo-go"` when you are done. ### Development build -A development build is the app's own binary with `expo-dev-client`, so it loads JavaScript from Metro like Expo Go but carries native modules Expo Go lacks: speech recognition for dictation now, and later Google sign-in (Expo Go cannot receive OAuth redirects) and the lock-screen timer. Its Info.plist also gets the 120 Hz key, which Expo Go ignores. JavaScript-only changes never need a new build. +A development build is the app's own binary with `expo-dev-client`, so it loads JavaScript from Metro like Expo Go but carries native modules Expo Go lacks: speech recognition for voice rounds and dictation, the timer's Live Activity and lock-screen widget (with an App Group), and later Google sign-in (Expo Go cannot receive OAuth redirects). Its Info.plist also gets the 120 Hz key, which Expo Go ignores. JavaScript-only changes never need a new build. [`apps/mobile/app.config.ts`](../apps/mobile/app.config.ts) builds one config with two switches: - No `DEBATELAB_BUILD`: the Expo Go and web export config, unchanged. - `DEBATELAB_BUILD=dev`: adds `expo-dev-client`, `expo-speech-recognition` with the microphone and speech prompts (English in Info.plist, Spanish and Hungarian as `InfoPlist.strings` from `mobile.permissions` in the catalogs) and `CFBundleAllowMixedLocalizations`. The `development` profile in [`apps/mobile/eas.json`](../apps/mobile/eas.json) sets it. -- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.adam.debatelab.ExpoWidgetsTarget`) and the App Group on both targets, which a Live Activity needs. Set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. +- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.adam.debatelab.ExpoWidgetsTarget`) with the timer's lock-screen widget (`DebateTimerWidget`) and Live Activity, `NSSupportsLiveActivities`, and the App Group on both targets. The group to register in Adam's Apple Developer account is `group.com.adam.debatelab`, on both bundle ids. Set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. `nativeCapabilities()` in `apps/mobile/src/lib/native-capabilities.ts` tells the app at runtime what the binary has. Feature code checks it and never imports a dev-build module at the top level, so Expo Go keeps working. From 3d6b51b59e357954f328cc57566eb5193f43882a Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 18:39:58 +0000 Subject: [PATCH 07/11] Pick the scorecard's PDF item by name in the screenshot run A wide iPad's outline rail is a menu too, so the first menu item was the rail's. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/scripts/screenshots.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index bff498dc6..42211edf3 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -1639,8 +1639,9 @@ async function shootVoiceScreens(context: BrowserContext, prefix: string, langua await page.waitForTimeout(400); await shoot(page, `${prefix}-voice-scorecard`); // The scorecard's PDF (export.web.ts keeps the HTML expo-print would print), rendered as a page of its own. + // A wide iPad's outline rail is a menu too; the export is the item named PDF in every language. await page.getByTestId('document-more').click(); - await page.getByRole('menuitem').first().click(); + await page.getByRole('menuitem', { name: /PDF/ }).click(); const exported = await page.evaluate(() => (window as unknown as { __debatelabExport?: { html?: string; fileName?: string } }).__debatelabExport ?? {}); if (!exported.html || !exported.fileName?.startsWith('SparringTranscript_')) problems.push(`${prefix} voice: the scorecard PDF was ${exported.fileName ?? 'not made'}`); else { From 6464cea48e3775590cf7d7b68abc4cccb5fe29a7 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 19:04:49 +0000 Subject: [PATCH 08/11] Fix review findings in voice rounds, dictation and the timer store - Words heard after a Sparring Reset no longer start the reset turn's clock: dictated words append to the draft without the start path. - One capture uses iOS's recognizer at a time; starting another cancels the first, so two screens never end or hear each other's sessions. - An opponent clip that never reports its end gives up after its length plus slack, so the device voice reads the rest; clips fetched for a reply that stopped are deleted. - Sending a spoken turn with nothing heard returns quietly. - Generate's dictation keeps words that arrive between renders. - A custom format saved before the stored list loads no longer replaces it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/generate.tsx | 9 +++- apps/mobile/src/app/sparring/index.tsx | 12 +++++- apps/mobile/src/lib/opponent-voice.ts | 50 +++++++++++++++------- apps/mobile/src/lib/speech-capture.test.ts | 16 +++++++ apps/mobile/src/lib/speech-capture.ts | 10 ++++- apps/mobile/src/lib/timer-store.ts | 16 +++++-- 6 files changed, 89 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/app/generate.tsx b/apps/mobile/src/app/generate.tsx index bfdceb649..d3d7b32fa 100644 --- a/apps/mobile/src/app/generate.tsx +++ b/apps/mobile/src/app/generate.tsx @@ -64,9 +64,14 @@ export default function GenerateScreen() { const abort = useRef(null); const live = useRef(true); const liveRun = useLiveRun(); - // Dictation fills the motion, as the website's microphone beside it does. + // Dictation fills the motion, as the website's microphone beside it does. The + // ref holds words that arrived before the last ones were drawn. + const motionRef = useRef(form.motion); + motionRef.current = form.motion; const dictation = useSpeechCapture(language, (text) => { - setForm({ motion: appendDictation(lastForm.get().motion, text, MAX_MOTION_LENGTH) }); + const motion = appendDictation(motionRef.current, text, MAX_MOTION_LENGTH); + motionRef.current = motion; + setForm({ motion }); setMotionError(false); }); diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 03d66aa46..13090a17d 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -342,6 +342,8 @@ export default function SparringScreen() { else void capture.stop(); const latest = sessionRef.current; if (!latest || latest.id !== before.id || latest.currentTurnIndex !== before.currentTurnIndex) return; + // Send pressed while listening, and nothing was heard: there is no speech to send yet. + if (!latest.draft.trim()) return; const delivery = voiceActiveRef.current ? measureDelivery(capture.getTake(), outputLanguage) : null; const next = submitSparringSpeech(latest, latest.draft, { now: Date.now(), createId: newSparringId, delivery }); if (!next) { @@ -399,8 +401,14 @@ export default function SparringScreen() { }); }; - // Recognised words join the draft, so the student can see and fix them. - const capture = useSpeechCapture(outputLanguage, (text) => updateDraft(appendDictation(sessionRef.current?.draft ?? '', text, MAX_SPARRING_SPEECH_CHARS))); + // Recognised words join the draft, so the student can see and fix them. They + // never start the clock: speaking starts it (onStart), and the last words + // after a Reset must not set the reset turn running again. + const capture = useSpeechCapture(outputLanguage, (text) => move((round, now) => ({ + ...round, + draft: appendDictation(round.draft, text, MAX_SPARRING_SPEECH_CHARS), + updatedAt: now, + }))); // Each turn of each round is measured on its own, and a start still waiting for permission does not carry over. const turnKey = session ? `${session.id}:${currentSparringTurn(session)?.id ?? ''}` : ''; const cancelCapture = capture.cancel; diff --git a/apps/mobile/src/lib/opponent-voice.ts b/apps/mobile/src/lib/opponent-voice.ts index 976a43264..aade94dbd 100644 --- a/apps/mobile/src/lib/opponent-voice.ts +++ b/apps/mobile/src/lib/opponent-voice.ts @@ -71,6 +71,17 @@ async function requestSegmentAudio(feature: VoiceFeature, segment: VoiceSegment, } let clipNumber = 0; +/** How long a clip may play past its length, or at all when its length never loads, before the device voice goes on. */ +const CLIP_SLACK_MS = 5_000; +const CLIP_WITHOUT_LENGTH_MS = 20_000; + +function deleteClip(clip: File | null) { + try { + clip?.delete(); + } catch { + // Already gone; iOS clears the cache anyway. + } +} class OpponentVoice { private state: OpponentVoiceState = { status: 'idle', engine: null, notice: null }; @@ -157,14 +168,23 @@ class OpponentVoice { if (segments.length > 0 && !this.providerOff) { this.setState({ status: 'loading', engine: 'elevenlabs', notice: null }); // Fetch one segment ahead so speech does not stall between segments. - let pending = this.load(utterance.feature, segments[0], signal); - for (; next < segments.length; next += 1) { - const clip = await pending; - if (run !== this.run) return; - if (!clip) break; - pending = next + 1 < segments.length ? this.load(utterance.feature, segments[next + 1], signal) : Promise.resolve(null); - const played = await this.playClip(clip, run); - if (!played) return; + let pending: Promise = this.load(utterance.feature, segments[0], signal); + try { + for (; next < segments.length; next += 1) { + const clip = await pending; + pending = Promise.resolve(null); + if (run !== this.run) { + deleteClip(clip); + return; + } + if (!clip) break; + pending = next + 1 < segments.length ? this.load(utterance.feature, segments[next + 1], signal) : Promise.resolve(null); + const played = await this.playClip(clip, run); + if (!played) return; + } + } finally { + // A segment fetched ahead for a reply that stopped is not played; its file goes too. + void pending.then(deleteClip); } } if (run !== this.run) return; @@ -204,15 +224,19 @@ class OpponentVoice { this.setState({ status: 'speaking', engine: 'elevenlabs' }); const player = createAudioPlayer(clip.uri); this.player = player; + const startedAt = Date.now(); await new Promise((resolve) => { const subscription = player.addListener('playbackStatusUpdate', (status) => { if (!status.didJustFinish) return; subscription.remove(); resolve(); }); - // Stopping releases the player; the run check below ends the wait. const check = setInterval(() => { - if (run === this.run && this.player === player) return; + // Stopping releases the player. A clip that never reports its end (it + // failed to load, or a call interrupted it) gives up after its length + // and some slack, so the rest of the reply still gets read. + const limit = (player.duration > 0 ? player.duration * 1_000 : CLIP_WITHOUT_LENGTH_MS) + CLIP_SLACK_MS; + if (run === this.run && this.player === player && Date.now() - startedAt < limit) return; clearInterval(check); subscription.remove(); resolve(); @@ -222,11 +246,7 @@ class OpponentVoice { if (this.player === player) this.releasePlayer(); return run === this.run; } finally { - try { - clip.delete(); - } catch { - // The cache is cleared by iOS anyway. - } + deleteClip(clip); } } diff --git a/apps/mobile/src/lib/speech-capture.test.ts b/apps/mobile/src/lib/speech-capture.test.ts index 63fd2e534..5c21cf2d4 100644 --- a/apps/mobile/src/lib/speech-capture.test.ts +++ b/apps/mobile/src/lib/speech-capture.test.ts @@ -103,4 +103,20 @@ describe('speech capture', () => { expect(await starting).toBe(false); expect(fake.starts).toBe(0); }); + + it('lets one capture use the recognizer at a time: starting another cancels the first', async () => { + const fake = fakeRecognizer(); + const heard: string[] = []; + const first = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: (text) => heard.push(`first: ${text}`) }); + const second = new SpeechCapture(fake.recognizer, { lang: 'en-US', onFinalText: (text) => heard.push(`second: ${text}`) }); + await first.start(); + await second.start(); + expect(first.getState().listening).toBe(false); + // The first no longer hears the shared recognizer, nor restarts when the second's session ends. + fake.heard('only for the second'); + fake.emit('end'); + expect(heard).toEqual(['second: only for the second']); + expect(fake.starts).toBe(3); + await second.stop(); + }); }); diff --git a/apps/mobile/src/lib/speech-capture.ts b/apps/mobile/src/lib/speech-capture.ts index b610581da..27bb1c2b7 100644 --- a/apps/mobile/src/lib/speech-capture.ts +++ b/apps/mobile/src/lib/speech-capture.ts @@ -34,6 +34,11 @@ export interface CaptureOptions { const emptyTake = (): SpeechTake => ({ text: '', speakingMs: 0, pausesMs: [] }); +// iOS has one recognizer for the app, and every capture listens to its events. +// Only one capture may use it at a time: starting one cancels any other, so a +// screen left listening under another never hears, or ends, the new one's session. +const owner: { current: SpeechCapture | null } = { current: null }; + export class SpeechCapture { private state: CaptureState = { listening: false, interim: '', error: null }; private listeners = new Set<() => void>(); @@ -84,6 +89,7 @@ export class SpeechCapture { private detach() { this.subscriptions.forEach((subscription) => subscription.remove()); this.subscriptions = []; + if (owner.current === this) owner.current = null; } private finishListening() { @@ -124,7 +130,9 @@ export class SpeechCapture { } start = async (): Promise => { - if (this.want) return false; + if (this.want || this.state.listening) return false; + if (owner.current && owner.current !== this) owner.current.cancel(); + owner.current = this; this.set({ error: null }); this.want = true; const attempt = ++this.attempt; diff --git a/apps/mobile/src/lib/timer-store.ts b/apps/mobile/src/lib/timer-store.ts index 62816685d..ff8391630 100644 --- a/apps/mobile/src/lib/timer-store.ts +++ b/apps/mobile/src/lib/timer-store.ts @@ -18,6 +18,8 @@ interface TimerStoreState { } let state: TimerStoreState = { loaded: false, session: initialSession(), saved: [] }; +// What changed before the stored copy was read; that change wins over the copy. +const changedBeforeLoad = { session: false, saved: false }; const listeners = new Set<() => void>(); let loading: Promise | null = null; @@ -43,12 +45,15 @@ async function readJson(key: string): Promise { export function loadTimer(): Promise { loading ??= (async () => { const [session, saved] = await Promise.all([readJson(SESSION_KEY), readJson(FORMATS_KEY)]); - // A change made before the read finished (a tap on a fresh screen) wins over the stored round. + // A change made before the read finished (a tap on a fresh screen) wins over the stored copy. + // Saved formats are merged, so one saved early does not drop the others. + const stored = parseSavedFormats(saved); state = { loaded: true, - session: state.loaded ? state.session : parseSession(session) ?? state.session, - saved: state.loaded ? state.saved : parseSavedFormats(saved), + session: changedBeforeLoad.session ? state.session : parseSession(session) ?? state.session, + saved: changedBeforeLoad.saved ? state.saved.reduce((list, entry) => upsertSavedFormat(list, entry), stored) : stored, }; + if (changedBeforeLoad.saved) persist(FORMATS_KEY, state.saved); emit(); })(); return loading; @@ -61,18 +66,21 @@ export function getTimer(): TimerStoreState { export function updateSession(change: (current: TimerSession) => TimerSession) { const next = change(state.session); if (next === state.session) return; - state = { ...state, loaded: true, session: next }; + if (!state.loaded) changedBeforeLoad.session = true; + state = { ...state, session: next }; persist(SESSION_KEY, serializeSession(next)); emit(); } export function saveFormat(entry: SavedFormat) { + if (!state.loaded) changedBeforeLoad.saved = true; state = { ...state, saved: upsertSavedFormat(state.saved, entry) }; persist(FORMATS_KEY, state.saved); emit(); } export function deleteSavedFormat(id: string) { + if (!state.loaded) changedBeforeLoad.saved = true; state = { ...state, saved: state.saved.filter((item) => item.id !== id) }; persist(FORMATS_KEY, state.saved); emit(); From c7e15c2c2a3433ec15c53262c8e16bbb741d0af6 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 19:19:52 +0000 Subject: [PATCH 09/11] Use the com.pistack.debateowl bundle id, its App Group and the EAS project The app is com.pistack.debateowl, its widget extension com.pistack.debateowl.ExpoWidgetsTarget, and both carry the App Group group.com.pistack.debateowl, the only value DEBATELAB_APP_GROUP accepts. The config links the EAS project @pi-stack/debateowl that eas init created, and eas.json gains a development-simulator profile: an unsigned development client for the iOS Simulator, with the widget extension. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 4 ++-- apps/mobile/app.config.ts | 22 ++++++++++++++-------- apps/mobile/eas.json | 12 ++++++++++++ docs/development.md | 25 ++++++++++++++----------- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a08a25b21..3a8280398 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -1,6 +1,6 @@ # DebateOwl mobile app -The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.debatelab`): Expo SDK 57, React Native 0.86, React 19, Expo Router. The plan is [plans/2026-10-08-native-ios-app.md](../../plans/2026-10-08-native-ios-app.md). Read the root [AGENTS.md](../../AGENTS.md) first; this file adds only what is specific to the app. +The iOS app DebateOwl (`@debatelab/mobile`; bundle id `com.pistack.debateowl`, EAS project `@pi-stack/debateowl`): Expo SDK 57, React Native 0.86, React 19, Expo Router. The plan is [plans/2026-10-08-native-ios-app.md](../../plans/2026-10-08-native-ios-app.md). Read the root [AGENTS.md](../../AGENTS.md) first; this file adds only what is specific to the app. ## Rules @@ -45,7 +45,7 @@ The iOS app DebateOwl (`@debatelab/mobile`; the bundle id is still `com.adam.deb - 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. - Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts`, `sparring.ts` and `history.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats, judged Sparring rounds and History's lists) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` (including its iPad outline rail), the Flowchart and the iPad Library list until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. `debateListScopes` also names History's copy (`one-off` for work outside every debate); History's add-to-debate and filing a judged Sparring round into its debate both drop the item's copies on both sides, and Delete all my data drops every copy (`ALL_COPIES`). - Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script. - - No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. + - A token needs `app.config.ts`'s `extra.eas.projectId`, which `eas init` set to the `@pi-stack/debateowl` project (see [docs/development.md](../../docs/development.md#build-day)); without it the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well. - Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go. - iPad layouts (iPad mock A, with mock C's docked claim) come from one hook, `useLayoutClass()` in `src/lib/layout.ts`, which also holds every width they use: `phone` on every iPhone and in iPad windows under 700 points, `regular` from 700, `wide` from 1100. Phone layouts must not change, so each screen keeps its phone JSX and branches before it; compare iPhone screenshots before and after. NativeTabs is `sidebarAdaptable` (the iPadOS 18 sidebar; `app-tabs.web.tsx` draws a sidebar or a top tab bar for the preview). The Library opens a source beside its list (`SourceView`, shared with the source sheet), Ask lists its conversations beside the chat (`ask-chat-list.tsx`), the Debate tab is a two-column dashboard, saved documents get an "On this page" rail from 900 points (`DocumentScreen`'s `outline`, `components/outline.tsx`), and the Flowchart docks a card's claim beside the map (`ClaimPanel` in `claim-panel.tsx`, shared with the claim sheet). A docked claim or a source open beside its list is dropped when that map, debate, account or row is no longer the one on screen. All are plain views sized from the window, so they run in Expo Go. - `src/theme/`: the website's DebateOwl design tokens (`src/styles/tokens.css` and the roles in `src/index.css`) in light and dark. `primary` is the blue that passes as text; `primaryFill` with `primaryLedge` is the button face and the ledge it sits on. Success is mint and always comes with a check mark. Fredoka (`weight="display"`) is for titles, the motion and button labels, Nunito for everything else, loaded with expo-font from `@expo-google-fonts`. Buttons are the website's tactile ones (`components/buttons.tsx`), which sink onto their ledge while pressed. Cards and fields have 2pt edges. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0d2a8ba4a..4fbd4d87f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -8,11 +8,11 @@ import { type ConfigPlugin, withEntitlementsPlist } from 'expo/config-plugins'; // DEBATELAB_BUILD=dev (set by the `development` profile in eas.json) adds the // native modules Expo Go lacks. Without it the config is the one Expo Go has // always used. DEBATELAB_APP_GROUP adds the timer's widget extension (its Live -// Activity, Dynamic Island clock and lock-screen widget), which needs an App -// Group: set it only when the provisioning profile carries that group. -// PLACEHOLDER until Adam's Apple Developer account exists: the group to create -// there is `group.com.adam.debatelab` (it follows the bundle id), registered -// for both `com.adam.debatelab` and `com.adam.debatelab.ExpoWidgetsTarget`. +// Activity, Dynamic Island clock and lock-screen widget), which needs the App +// Group `group.com.pistack.debateowl`, registered for both +// `com.pistack.debateowl` and `com.pistack.debateowl.ExpoWidgetsTarget`. On a +// device, set it only when the provisioning profile carries that group; a +// simulator build is not signed and always can. // See docs/development.md#development-build. type PermissionStrings = { speechRecognition: string; microphone: string }; @@ -26,8 +26,11 @@ if (appGroup && build !== 'dev') { throw new Error('DEBATELAB_APP_GROUP only applies to a development build (DEBATELAB_BUILD=dev).'); } -// Kept from the DebateLab name until Adam decides on the store listing: a new id is a new app in App Store Connect. -const bundleIdentifier = 'com.adam.debatelab'; +const bundleIdentifier = 'com.pistack.debateowl'; +const APP_GROUP = 'group.com.pistack.debateowl'; +if (appGroup && appGroup !== APP_GROUP) { + throw new Error(`DEBATELAB_APP_GROUP must be "${APP_GROUP}", not "${appGroup}".`); +} function permissionStrings(projectRoot: string, language: 'en' | 'es' | 'hu'): PermissionStrings { const file = path.join(projectRoot, '../../src/locales', `${language}.json`); @@ -97,8 +100,11 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { }, ], ], + // The EAS project @pi-stack/debateowl, from `eas init`. + owner: 'pi-stack', extra: { websiteUrl: 'https://debateowl.com', + eas: { projectId: 'd60fc378-2e30-48b2-bcee-a1b1d1632bf9' }, }, experiments: { typedRoutes: true, @@ -127,7 +133,7 @@ export default ({ projectRoot }: ConfigContext): ExpoConfig => { 'expo-widgets', { bundleIdentifier: `${bundleIdentifier}.ExpoWidgetsTarget`, - groupIdentifier: appGroup, + groupIdentifier: APP_GROUP, enablePushNotifications: false, widgets: [ // The timer's lock-screen widget (src/widgets/timer-widgets.tsx). The Live diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 3867fcac2..d77718c80 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -12,6 +12,18 @@ "env": { "DEBATELAB_BUILD": "dev" } + }, + "development-simulator": { + "developmentClient": true, + "distribution": "internal", + "node": "22.23.2", + "ios": { + "simulator": true + }, + "env": { + "DEBATELAB_BUILD": "dev", + "DEBATELAB_APP_GROUP": "group.com.pistack.debateowl" + } } } } diff --git a/docs/development.md b/docs/development.md index c9f092d4c..cd229cb7f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,7 +143,7 @@ A development build is the app's own binary with `expo-dev-client`, so it loads - No `DEBATELAB_BUILD`: the Expo Go and web export config, unchanged. - `DEBATELAB_BUILD=dev`: adds `expo-dev-client`, `expo-speech-recognition` with the microphone and speech prompts (English in Info.plist, Spanish and Hungarian as `InfoPlist.strings` from `mobile.permissions` in the catalogs) and `CFBundleAllowMixedLocalizations`. The `development` profile in [`apps/mobile/eas.json`](../apps/mobile/eas.json) sets it. -- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.adam.debatelab.ExpoWidgetsTarget`) with the timer's lock-screen widget (`DebateTimerWidget`) and Live Activity, `NSSupportsLiveActivities`, and the App Group on both targets. The group to register in Adam's Apple Developer account is `group.com.adam.debatelab`, on both bundle ids. Set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. +- `DEBATELAB_APP_GROUP=group.`, only with `dev`: adds the `expo-widgets` extension (`ExpoWidgetsTarget`, bundle `com.pistack.debateowl.ExpoWidgetsTarget`) with the timer's lock-screen widget (`DebateTimerWidget`) and Live Activity, `NSSupportsLiveActivities`, and the App Group `group.com.pistack.debateowl` (the only value the config accepts) on both targets. Register that group for both bundle ids in the Apple Developer account. For a device build, set it only when the provisioning profile carries that group; a borrowed wildcard profile does not. A simulator build is not signed, so the `development-simulator` profile always sets it. EAS does not see your shell, so it goes in the profile's `env` in `eas.json`, committed with the build. `nativeCapabilities()` in `apps/mobile/src/lib/native-capabilities.ts` tells the app at runtime what the binary has. Feature code checks it and never imports a dev-build module at the top level, so Expo Go keeps working. @@ -190,21 +190,13 @@ openssl pkcs12 -legacy -in $S/cert.p12 -passin file:$S/p12-password -nokeys -clc Go on only if all of these hold: - the profile has not expired, and neither has the certificate (`notAfter`); -- `application-identifier` is `.*` or `.com.adam.debatelab`; +- `application-identifier` is `.*` or `.com.pistack.debateowl`; - the profile is enterprise (`all devices` true) or ad hoc with Adam's iPhone UDID among its devices: ask Adam for the UDID. A development profile (`get-task-allow` true) is not what EAS internal distribution expects; ask Adam first; - the `.p12`'s SHA-1 equals one of the profile's certificate SHA-1s. Set `DEBATELAB_APP_GROUP` in the profile's `env` in `eas.json` only if `app groups` lists a group, and then pass `widgets` to step 4. Don't export it in this shell: outside a build the config rejects it without `DEBATELAB_BUILD=dev`, which breaks `eas init`. Without an App Group this build has no Live Activity. -**2. Link the EAS project, once.** - -```bash -cd apps/mobile -export EXPO_TOKEN="$(cat $S/eas-token)" -npx eas-cli@24.13.0 init --account --force --non-interactive -``` - -Without a project ID in the config, the non-interactive init needs both `--account` (the Expo account that owns the project, `adamgaborjani` on Adam's VPS) and `--force`, which links `@/debatelab` if it exists and creates it otherwise. `eas init` cannot edit `app.config.ts`, so it prints the project ID. Add `owner: ''` and `extra: { eas: { projectId: '' } }` to the config, commit, and build from that commit. Expo Go's config gains those two fields, which is expected. +**2. The EAS project is linked.** `app.config.ts` has `owner: 'pi-stack'` and the project ID of `@pi-stack/debateowl`, set with `eas init --account pi-stack --force --non-interactive` (which cannot edit a dynamic config, so it printed the ID). Nothing to do here unless the project changes. **3. Count the builds already used.** @@ -253,6 +245,17 @@ rm -f credentials.json $S/profile.plist unset EXPO_TOKEN ``` +#### Simulator build + +`development-simulator` in `eas.json` builds the development client for the iOS Simulator, with the widget extension and App Group. It is not signed, so it needs no credentials, only a logged-in `eas` (`eas whoami`). It costs build minutes like any iOS build: + +```bash +cd apps/mobile +eas build --platform ios --profile development-simulator --non-interactive --no-wait +``` + +The artifact is a `.tar.gz` of the `.app`; drag the unpacked app onto a Simulator, then start Metro with `--dev-client`. + #### Installing it The finished build's page on expo.dev has an Install button and a QR code. Adam opens it on the iPhone in Safari and installs. For an enterprise certificate, iOS then asks to trust the developer under Settings › General › VPN & Device Management. If iOS asks for Developer Mode, it is under Settings › Privacy & Security. From f29e1864401c92a845ebadea7930f4234167d642 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 19:41:27 +0000 Subject: [PATCH 10/11] Let expo-widgets bundle its widget runtime in the npm workspace EAS build b8e4ba30 failed in the Xcode phase that bundles the widget runtime: expo-widgets' metro.config.js makes the package its project root, and Expo CLI's resolver then cannot reach Metro's empty module in the workspace root's node_modules. An eas-build-post-install hook gives that bundle an empty module inside the package; it fails loudly if a later expo-widgets changes the config it edits. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 3 +- .../scripts/patch-expo-widgets-bundle.mjs | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/scripts/patch-expo-widgets-bundle.mjs diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f9ea3481d..b0435831b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "expo start --go", "export:web": "expo export --platform web", - "typecheck": "tsc --noEmit && tsc --noEmit -p scripts" + "typecheck": "tsc --noEmit && tsc --noEmit -p scripts", + "eas-build-post-install": "node scripts/patch-expo-widgets-bundle.mjs" }, "dependencies": { "@debatelab/core": "*", diff --git a/apps/mobile/scripts/patch-expo-widgets-bundle.mjs b/apps/mobile/scripts/patch-expo-widgets-bundle.mjs new file mode 100644 index 000000000..95466ad97 --- /dev/null +++ b/apps/mobile/scripts/patch-expo-widgets-bundle.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Lets expo-widgets bundle its widget runtime in this npm workspace. EAS runs + * it after installing (`eas-build-post-install` in package.json); it is safe + * to run again. + * + * During the Xcode build, expo-widgets bundles its runtime with its own + * metro.config.js, which makes the package itself the project root. That + * bundle resolves `react-native/*` and worklets imports to Metro's empty + * module, which Metro finds in the workspace root's node_modules. Expo CLI's + * resolver does not find files there from that project root, so the build + * fails with "Unable to resolve module .../metro-runtime/src/modules/empty-module.js" + * (EAS build b8e4ba30, 2026-10-28). An empty module inside the package + * resolves. Remove this once expo-widgets handles hoisted workspaces. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const packageDir = path.dirname(require.resolve('expo-widgets/package.json')); +const configPath = path.join(packageDir, 'metro.config.js'); +const emptyModule = path.join(packageDir, 'bundle', 'empty-module.js'); + +writeFileSync(emptyModule, '// The widget bundle\'s empty module (apps/mobile/scripts/patch-expo-widgets-bundle.mjs).\n'); + +const config = readFileSync(configPath, 'utf8'); +if (config.includes("'bundle/empty-module.js'")) { + console.log('expo-widgets: metro.config.js already uses its own empty module.'); +} else { + const anchor = ' resolver: {\n ...config.resolver,\n'; + if (!config.includes(anchor)) { + throw new Error(`expo-widgets changed ${configPath}; check whether this patch is still needed.`); + } + writeFileSync(configPath, config.replace(anchor, `${anchor} emptyModulePath: path.join(__dirname, 'bundle/empty-module.js'),\n`)); + console.log('expo-widgets: metro.config.js now uses bundle/empty-module.js.'); +} From 5f3e22f97f069820f867f7ec53477b91d0dbacec Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 28 Oct 2026 20:03:46 +0000 Subject: [PATCH 11/11] Show the Sparring word count and match the timer widget to the timer colours The live round passed no formatted number, so it read {{formatted}} words. The lock-screen widget now uses the timer screen colours for side B and both sides. --- apps/mobile/src/app/sparring/index.tsx | 4 ++-- apps/mobile/src/widgets/timer-widgets.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 13090a17d..6dd15b2a5 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -567,7 +567,7 @@ function LiveRound({ onSubmit: () => void; onRetry: () => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); @@ -731,7 +731,7 @@ function LiveRound({ testID="sparring-input" /> - {t('sparring.round.wordCount', { count: words })} + {t('sparring.round.wordCount', { count: words, formatted: words.toLocaleString(i18n.language) })}
) : null} diff --git a/apps/mobile/src/widgets/timer-widgets.tsx b/apps/mobile/src/widgets/timer-widgets.tsx index 8ecba2515..6af63baec 100644 --- a/apps/mobile/src/widgets/timer-widgets.tsx +++ b/apps/mobile/src/widgets/timer-widgets.tsx @@ -9,7 +9,7 @@ import type { TimerActivityState } from '@/lib/timer'; // the app running. Each layout runs in the widget extension's own runtime // ('widget' directive), so it cannot use anything outside its function: every // string comes in the props, in the app's language, and the colours are the -// app's plume blue (side A), coral (side B) and ink (both sides). +// timer screen's dark side colours: plume blue (side A), rose (side B) and slate (both sides). // // Only live-activity.ts loads this file, and only in a build that has the // widget extension (nativeCapabilities().liveActivity). Expo Go never does. @@ -24,7 +24,7 @@ export interface TimerWidgetProps { const DebateTimerActivity = (props: TimerActivityState, environment: LiveActivityEnvironment) => { 'widget'; const HOUR = 3_600_000; - const accent = props.side === 'a' ? '#4CA6FD' : props.side === 'b' ? '#FF8A8E' : '#A9B7C9'; + const accent = props.side === 'a' ? '#4CA6FD' : props.side === 'b' ? '#FF8AA8' : '#8E9FB6'; const at = props.pausedAt ?? Date.now(); // A speech past its limit counts on as overtime; iOS marks the activity stale at the limit. const over = props.kind === 'speech' && (environment.isStale === true || at >= props.endsAt);