From 1318a325c527dba0d0c0e3852be47a99089c6e89 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:18:01 +0000 Subject: [PATCH 01/12] Load debates and settings from the website's tables in the app The app reads and writes debates, current_debates and user_settings the way the website's stores do, so both share one Current Debate and one set of settings. One-off mode stays session-only, as on the website. The debate side is kept on the device, because debates has no side column. The interface language and theme follow the account's saved choice. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/app/_layout.tsx | 12 +- apps/mobile/src/lib/debate-context.tsx | 156 +++++++++++++ apps/mobile/src/lib/debates.ts | 207 ++++++++++++++++++ apps/mobile/src/lib/i18n.ts | 4 +- apps/mobile/src/lib/settings.tsx | 146 ++++++++++++ apps/mobile/src/theme/apply-color-scheme.ts | 6 + .../src/theme/apply-color-scheme.web.ts | 2 + apps/mobile/src/theme/use-theme.ts | 7 +- 8 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/lib/debate-context.tsx create mode 100644 apps/mobile/src/lib/debates.ts create mode 100644 apps/mobile/src/lib/settings.tsx create mode 100644 apps/mobile/src/theme/apply-color-scheme.ts create mode 100644 apps/mobile/src/theme/apply-color-scheme.web.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f692d7d6d..aa5473f42 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -8,6 +8,8 @@ import { useEffect } from 'react'; import { StyleSheet, View } from 'react-native'; import { AuthProvider, useAuth } from '@/lib/auth'; +import { DebateProvider } from '@/lib/debate-context'; +import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -16,7 +18,11 @@ void SplashScreen.preventAutoHideAsync(); export default function RootLayout() { return ( - + + + + + ); } @@ -24,8 +30,10 @@ export default function RootLayout() { function Root() { const [fontsLoaded, fontError] = useFonts(interFonts); const { session, loading, settled } = useAuth(); + const { ready: settingsReady } = useSettings(); const { scheme, colors } = useTheme(); - const ready = (fontsLoaded || fontError !== null) && !loading; + // Settings come from the device here, so the app opens in the account's language and theme. + const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady; useEffect(() => { if (ready) void SplashScreen.hideAsync(); diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx new file mode 100644 index 000000000..b88f32356 --- /dev/null +++ b/apps/mobile/src/lib/debate-context.tsx @@ -0,0 +1,156 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { AppState } from 'react-native'; + +import { useAuth } from './auth'; +import { + createDebate, + fetchDebates, + sortDebates, + switchDebate, + type Debate, + type DebateSettings, +} from './debates'; + +/** The side the student argues in a debate. */ +export type DebateSide = 'pro' | 'con'; + +type Status = 'loading' | 'ready' | 'error'; + +type DebateContextValue = { + status: Status; + debates: Debate[]; + current: Debate | null; + /** Session-only, as on the website: tools run without reading from or saving to any debate. */ + oneOff: boolean; + /** The debate that new work attaches to right now, or null for one-off work. */ + activeDebateId: string | null; + refresh: () => Promise; + create: (input: { motion: string; settings: DebateSettings; side: DebateSide }) => Promise; + switchTo: (id: string) => Promise; + setOneOff: (oneOff: boolean) => void; + sideOf: (debateId: string) => DebateSide; + setSide: (debateId: string, side: DebateSide) => void; +}; + +const DebateContext = createContext(null); + +// The debates table has no side, and the website drops unknown keys from a +// debate's settings, so the side is kept on this device per account and debate. +const sideKey = (userId: string, debateId: string) => `debatelab.debate-side.${userId}.${debateId}`; + +export function DebateProvider({ children }: { children: ReactNode }) { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [status, setStatus] = useState('loading'); + const [debates, setDebates] = useState([]); + const [currentId, setCurrentId] = useState(null); + const [oneOff, setOneOff] = useState(false); + const [sides, setSides] = useState>({}); + // A load that started before a create or switch must not overwrite it, and + // nothing from a previous account may land after a sign-out. + const writes = useRef(0); + const owner = useRef(userId); + + const refresh = useCallback(async () => { + const forUser = owner.current; + if (!forUser) return; + const writesBefore = writes.current; + try { + const next = await fetchDebates(); + if (owner.current !== forUser || writes.current !== writesBefore) return; + setDebates(next.debates); + setCurrentId(next.currentId); + setStatus('ready'); + } catch (cause) { + if (owner.current !== forUser) return; + console.warn('Could not load debates:', cause instanceof Error ? cause.message : cause); + // Keep what is on screen; only an empty first load becomes an error. + setStatus((previous) => (previous === 'ready' ? previous : 'error')); + } + }, []); + + useEffect(() => { + owner.current = userId; + setDebates([]); + setCurrentId(null); + setOneOff(false); + setSides({}); + setStatus('loading'); + if (!userId) return; + void refresh(); + // Pick up debates started or switched on the website while the app was in the background. + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') void refresh(); + }); + return () => subscription.remove(); + }, [userId, refresh]); + + // Load the stored side for every debate this account has. + useEffect(() => { + if (!userId || debates.length === 0) return; + let cancelled = false; + void AsyncStorage.multiGet(debates.map((debate) => sideKey(userId, debate.id))).then((entries) => { + if (cancelled) return; + const loaded: Record = {}; + entries.forEach(([, value], index) => { + if (value === 'pro' || value === 'con') loaded[debates[index].id] = value; + }); + setSides((previous) => ({ ...loaded, ...previous })); + }).catch((cause: unknown) => console.warn('Could not read debate sides:', cause)); + return () => { + cancelled = true; + }; + }, [userId, debates]); + + const setSide = useCallback((debateId: string, side: DebateSide) => { + setSides((previous) => ({ ...previous, [debateId]: side })); + if (userId) { + AsyncStorage.setItem(sideKey(userId, debateId), side).catch((cause: unknown) => + console.warn('Could not save the debate side:', cause), + ); + } + }, [userId]); + + const value = useMemo(() => { + const current = debates.find((debate) => debate.id === currentId) ?? null; + return { + status, + debates, + current, + oneOff, + activeDebateId: oneOff ? null : current?.id ?? null, + refresh, + create: async ({ motion, settings, side }) => { + if (!userId) throw new Error('Sign in to manage debates'); + const debate = await createDebate(userId, { motion, settings }); + writes.current += 1; + setSide(debate.id, side); + setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); + setCurrentId(debate.id); + setOneOff(false); + setStatus('ready'); + return debate; + }, + switchTo: async (id) => { + if (!userId) throw new Error('Sign in to manage debates'); + const at = await switchDebate(userId, id); + writes.current += 1; + setDebates((previous) => sortDebates(previous.map((item) => (item.id === id ? { ...item, lastActivityAt: at } : item)))); + setCurrentId(id); + setOneOff(false); + }, + setOneOff, + sideOf: (debateId) => sides[debateId] ?? 'pro', + setSide, + }; + }, [status, debates, currentId, oneOff, sides, userId, refresh, setSide]); + + return {children}; +} + +export function useDebates(): DebateContextValue { + const value = useContext(DebateContext); + if (!value) throw new Error('useDebates must be used inside DebateProvider'); + return value; +} diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts new file mode 100644 index 000000000..a5d896a84 --- /dev/null +++ b/apps/mobile/src/lib/debates.ts @@ -0,0 +1,207 @@ +import { supabase } from './supabase'; + +/** + * The website's debate tables, read and written the way its debate store + * (src/lib/stores/debate-store.ts) does, so the app and the website share one + * Current Debate. The store itself stays in the website: it persists to + * localStorage and uses the web Supabase client. + */ + +export interface DebateSettings { + format?: string; + role?: string; + judge?: string; +} + +export interface Debate { + id: string; + motion: string; + settings: DebateSettings; + instructions: string; + lastActivityAt: number; + createdAt: number; +} + +export const MAX_MOTION_LENGTH = 1200; + +/** The web DebateForm's formats, in its order. */ +export const DEBATE_FORMATS = ['custom', 'bp', 'policy', 'classroom'] as const; +export type DebateFormat = (typeof DEBATE_FORMATS)[number]; + +const DEBATE_COLUMNS = 'id,motion,settings,instructions,last_activity_at,created_at'; + +interface DebateRow { + id: string; + motion: string; + settings: unknown; + instructions: string | null; + last_activity_at: string; + created_at: string; +} + +function parseSettings(value: unknown): DebateSettings { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const record = value as Record; + const settings: DebateSettings = {}; + for (const key of ['format', 'role', 'judge'] as const) { + if (typeof record[key] === 'string' && record[key]) settings[key] = record[key] as string; + } + return settings; +} + +function fromRow(row: DebateRow): Debate { + return { + id: row.id, + motion: row.motion, + settings: parseSettings(row.settings), + instructions: row.instructions ?? '', + lastActivityAt: new Date(row.last_activity_at).getTime(), + createdAt: new Date(row.created_at).getTime(), + }; +} + +export function sortDebates(debates: Debate[]): Debate[] { + return [...debates].sort((a, b) => b.lastActivityAt - a.lastActivityAt); +} + +async function setCurrentPointer(userId: string, debateId: string) { + const { error } = await supabase + .from('current_debates') + .upsert({ user_id: userId, debate_id: debateId, updated_at: new Date().toISOString() }, { onConflict: 'user_id' }); + if (error) throw new Error(error.message); +} + +/** Every debate, newest activity first, and the current one when it is still in the list. */ +export async function fetchDebates(): Promise<{ debates: Debate[]; currentId: string | null }> { + const [debatesResult, currentResult] = await Promise.all([ + supabase.from('debates').select(DEBATE_COLUMNS).order('last_activity_at', { ascending: false }), + supabase.from('current_debates').select('debate_id').maybeSingle(), + ]); + if (debatesResult.error) throw new Error(debatesResult.error.message); + if (currentResult.error) throw new Error(currentResult.error.message); + const debates = ((debatesResult.data ?? []) as DebateRow[]).map(fromRow); + const currentId = (currentResult.data as { debate_id?: string } | null)?.debate_id ?? null; + return { + debates: sortDebates(debates), + currentId: currentId && debates.some((debate) => debate.id === currentId) ? currentId : null, + }; +} + +/** Creates a debate and makes it current, as the website's create dialog does. */ +export async function createDebate( + userId: string, + input: { motion: string; settings: DebateSettings }, +): Promise { + const motion = input.motion.trim(); + if (!motion) throw new Error('A debate needs a motion'); + const { data, error } = await supabase + .from('debates') + .insert({ user_id: userId, motion: motion.slice(0, MAX_MOTION_LENGTH), settings: input.settings, instructions: '' }) + .select(DEBATE_COLUMNS) + .single(); + if (error || !data) throw new Error(error?.message ?? 'Could not create debate'); + const debate = fromRow(data as DebateRow); + await setCurrentPointer(userId, debate.id); + return debate; +} + +/** Makes a debate current. Choosing it also counts as activity, as on the website. */ +export async function switchDebate(userId: string, debateId: string): Promise { + await setCurrentPointer(userId, debateId); + const now = Date.now(); + // The website does not fail a switch when only this timestamp could not be saved. + await supabase.from('debates').update({ last_activity_at: new Date(now).toISOString() }).eq('id', debateId); + return now; +} + +/** Tables whose rows are a debate's saved results, as listed by the website's debate counts. */ +const RESULT_TABLES = ['cases', 'attacks', 'brainstorms', 'factoids', 'chats', 'flowcharts', 'crossfires'] as const; +type ResultTable = (typeof RESULT_TABLES)[number]; + +export type DebateItemKind = 'case' | 'attack' | 'brainstorm' | 'factoids' | 'chat' | 'flowchart' | 'crossfire' | 'sparring'; + +const KIND_BY_TABLE: Record = { + cases: 'case', + attacks: 'attack', + brainstorms: 'brainstorm', + factoids: 'factoids', + chats: 'chat', + flowcharts: 'flowchart', + crossfires: 'crossfire', +}; + +export interface DebateCounts { + sources: number; + results: number; + rounds: number; +} + +async function countRows(table: string, debateId: string): Promise { + const { count, error } = await supabase + .from(table) + .select('id', { count: 'exact', head: true }) + .eq('debate_id', debateId); + if (error) throw new Error(error.message); + return count ?? 0; +} + +/** Sources, saved results and Sparring rounds in one debate. One-off work has no debate and never counts. */ +export async function fetchDebateCounts(debateId: string): Promise { + const [sources, rounds, ...results] = await Promise.all([ + countRows('sources', debateId), + countRows('sparring_sessions', debateId), + ...RESULT_TABLES.map((table) => countRows(table, debateId)), + ]); + return { sources, rounds, results: results.reduce((sum, count) => sum + count, 0) }; +} + +/** Library sources per debate, for the switcher's list. */ +export async function fetchSourceCounts(debateIds: string[]): Promise> { + const counts = await Promise.all(debateIds.map((id) => countRows('sources', id))); + return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); +} + +export interface DebateItem { + id: string; + kind: DebateItemKind; + title: string; + createdAt: number; +} + +/** The newest saved results and rounds in a debate, like the website's Recent work. */ +export async function fetchRecentWork(debateId: string, limit: number): Promise { + const lists = await Promise.all([ + ...RESULT_TABLES.map(async (table) => { + const { data, error } = await supabase + .from(table) + .select('id,title,created_at') + .eq('debate_id', debateId) + .order('created_at', { ascending: false }) + .limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: KIND_BY_TABLE[table], + title: row.title, + createdAt: new Date(row.created_at).getTime(), + })); + }), + (async () => { + // The website lists a round under its motion and orders it by its last update. + const { data, error } = await supabase + .from('sparring_sessions') + .select('id,motion,updated_at') + .eq('debate_id', debateId) + .order('updated_at', { ascending: false }) + .limit(limit); + if (error) throw new Error(error.message); + return ((data ?? []) as { id: string; motion: string; updated_at: string }[]).map((row): DebateItem => ({ + id: row.id, + kind: 'sparring', + title: row.motion, + createdAt: new Date(row.updated_at).getTime(), + })); + })(), + ]); + return lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); +} diff --git a/apps/mobile/src/lib/i18n.ts b/apps/mobile/src/lib/i18n.ts index 8d93f192e..b41fa428b 100644 --- a/apps/mobile/src/lib/i18n.ts +++ b/apps/mobile/src/lib/i18n.ts @@ -8,8 +8,8 @@ import en from '../../../../src/locales/en.json'; import es from '../../../../src/locales/es.json'; import hu from '../../../../src/locales/hu.json'; -/** The device language when DebateLab has it, otherwise English. The settings choice arrives with the You tab. */ -function deviceLanguage(): AppLanguage { +/** The device language when DebateLab has it, otherwise English. Signed in, the account's choice wins (see settings.tsx). */ +export function deviceLanguage(): AppLanguage { const code = getLocales()[0]?.languageCode; return isAppLanguage(code) ? code : 'en'; } diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx new file mode 100644 index 000000000..78834ac94 --- /dev/null +++ b/apps/mobile/src/lib/settings.tsx @@ -0,0 +1,146 @@ +import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; + +import { useAuth } from './auth'; +import i18n, { deviceLanguage } from './i18n'; +import { supabase } from './supabase'; + +import { applyColorScheme } from '@/theme/apply-color-scheme'; + +export type ThemePreference = 'light' | 'dark' | 'system'; + +type Settings = { language: AppLanguage; outputLanguage: AppLanguage; theme: ThemePreference }; + +type SettingsContextValue = Settings & { + /** + * False until the settings to open with are known: the signed-in account's + * last known values from the device, or the device defaults. It never turns + * false again, because the root navigator must not unmount on sign-in. + */ + ready: boolean; + setLanguage: (language: AppLanguage) => Promise; + setOutputLanguage: (language: AppLanguage) => Promise; + setTheme: (theme: ThemePreference) => Promise; +}; + +// The columns each setting is stored in, in the website's user_settings row. +const COLUMNS = { language: 'language', outputLanguage: 'output_language', theme: 'theme' } as const; + +const SIGNED_OUT: Settings = { language: deviceLanguage(), outputLanguage: 'en', theme: 'system' }; + +const SettingsContext = createContext({ + ...SIGNED_OUT, + ready: true, + setLanguage: async () => {}, + setOutputLanguage: async () => {}, + setTheme: async () => {}, +}); + +function isThemePreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system'; +} + +function parseSettings(value: unknown): Settings | null { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + return { + language: isAppLanguage(record.language) ? record.language : 'en', + outputLanguage: isAppLanguage(record.outputLanguage ?? record.output_language) + ? (record.outputLanguage ?? record.output_language) as AppLanguage + : 'en', + theme: isThemePreference(record.theme) ? record.theme : 'system', + }; +} + +const cacheKey = (userId: string) => `debatelab.settings.${userId}`; + +/** + * Interface language, AI output language and theme from the account's + * user_settings row, shared with the website. The last known values are kept + * on the device so the app opens in the right language and theme. + */ +export function SettingsProvider({ children }: { children: ReactNode }) { + const { session, loading } = useAuth(); + const userId = session?.user.id ?? null; + const [settings, setSettings] = useState(SIGNED_OUT); + const [ready, setReady] = useState(false); + const owner = useRef(userId); + + const apply = useCallback((next: Settings, forUser: string | null) => { + if (owner.current !== forUser) return; + setSettings(next); + if (i18n.language !== next.language) void i18n.changeLanguage(next.language); + applyColorScheme(next.theme); + if (forUser) { + AsyncStorage.setItem(cacheKey(forUser), JSON.stringify(next)).catch((cause: unknown) => + console.warn('Could not keep settings on this device:', cause), + ); + } + }, []); + + useEffect(() => { + if (loading) return; + owner.current = userId; + if (!userId) { + apply({ ...SIGNED_OUT, language: deviceLanguage() }, null); + setReady(true); + return; + } + let cancelled = false; + void (async () => { + try { + const cached = parseSettings(JSON.parse((await AsyncStorage.getItem(cacheKey(userId))) ?? 'null')); + if (!cancelled && cached) apply(cached, userId); + } catch (cause) { + console.warn('Could not read settings from this device:', cause); + } + if (!cancelled) setReady(true); + const { data, error } = await supabase + .from('user_settings') + .select('language,output_language,theme') + .eq('user_id', userId) + .maybeSingle(); + if (cancelled) return; + if (error) { + console.warn('Could not load settings:', error.message); + return; + } + const server = parseSettings(data); + if (server) apply(server, userId); + })(); + return () => { + cancelled = true; + }; + }, [loading, userId, apply]); + + const save = useCallback(async (key: K, value: Settings[K]) => { + if (!userId) return; + const previous = settings; + apply({ ...settings, [key]: value }, userId); + // The sign-up trigger provisions the row and clients may only update it, + // as in the website's settings store. Only the changed column is written. + const { error } = await supabase + .from('user_settings') + .update({ [COLUMNS[key]]: value, updated_at: new Date().toISOString() }) + .eq('user_id', userId); + if (error) { + apply(previous, userId); + throw new Error(error.message); + } + }, [userId, settings, apply]); + + const value = useMemo(() => ({ + ...settings, + ready, + setLanguage: (language) => save('language', language), + setOutputLanguage: (language) => save('outputLanguage', language), + setTheme: (theme) => save('theme', theme), + }), [settings, ready, save]); + + return {children}; +} + +export function useSettings(): SettingsContextValue { + return useContext(SettingsContext); +} diff --git a/apps/mobile/src/theme/apply-color-scheme.ts b/apps/mobile/src/theme/apply-color-scheme.ts new file mode 100644 index 000000000..927f334bf --- /dev/null +++ b/apps/mobile/src/theme/apply-color-scheme.ts @@ -0,0 +1,6 @@ +import { Appearance } from 'react-native'; + +/** Native controls, sheets and the tab bar follow the theme chosen in You, not only the system's. */ +export function applyColorScheme(theme: 'light' | 'dark' | 'system') { + Appearance.setColorScheme(theme === 'system' ? 'unspecified' : theme); +} diff --git a/apps/mobile/src/theme/apply-color-scheme.web.ts b/apps/mobile/src/theme/apply-color-scheme.web.ts new file mode 100644 index 000000000..de2643432 --- /dev/null +++ b/apps/mobile/src/theme/apply-color-scheme.web.ts @@ -0,0 +1,2 @@ +/** The web preview draws everything from useTheme, so there is no native appearance to set. */ +export function applyColorScheme(_theme: 'light' | 'dark' | 'system') {} diff --git a/apps/mobile/src/theme/use-theme.ts b/apps/mobile/src/theme/use-theme.ts index cd52fd01b..4764e06ab 100644 --- a/apps/mobile/src/theme/use-theme.ts +++ b/apps/mobile/src/theme/use-theme.ts @@ -1,7 +1,12 @@ import { palette } from './colors'; import { useColorScheme } from './use-color-scheme'; +import { useSettings } from '@/lib/settings'; + +/** The theme chosen in You, or the system's when it is System. */ export function useTheme() { - const scheme = useColorScheme() === 'dark' ? 'dark' : 'light'; + const { theme } = useSettings(); + const system = useColorScheme() === 'dark' ? 'dark' : 'light'; + const scheme = theme === 'system' ? system : theme; return { scheme, colors: palette[scheme] } as const; } From ba7ac63baf482c3d322f9e1e27630db3ca4235a6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:18:10 +0000 Subject: [PATCH 02/12] Build the Debate tab, debate switcher and new-debate sheet The Debate tab follows mock 01: the Current debate pill, the motion as the title, the side control, counts of sources, results and rounds, the Spar against the AI card, the Prepare tiles and recent work. Tools and results open the existing placeholders until their screens land. The pill opens the switcher from mock 02 as a native form sheet: search, the debates with their source counts, one-off mode and New debate. New debate takes a motion, side and format and makes the debate current. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/(debate)/index.tsx | 397 +++++++++++++++++- apps/mobile/src/app/_layout.tsx | 12 + apps/mobile/src/app/debates.tsx | 224 ++++++++++ apps/mobile/src/app/new-debate.tsx | 151 +++++++ apps/mobile/src/components/buttons.tsx | 50 +++ apps/mobile/src/components/icon-paths.ts | 10 + apps/mobile/src/components/icon.tsx | 3 +- apps/mobile/src/components/screen.tsx | 4 +- apps/mobile/src/lib/dates.ts | 30 ++ package-lock.json | 1 + src/locales/en.json | 36 +- src/locales/es.json | 36 +- src/locales/hu.json | 36 +- 14 files changed, 971 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/app/debates.tsx create mode 100644 apps/mobile/src/app/new-debate.tsx create mode 100644 apps/mobile/src/lib/dates.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e18402c45..6dc48e4bf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,6 +14,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", + "date-fns": "^3.6.0", "expo": "~57.0.27", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 4996a3d12..0d28c8494 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -1,31 +1,400 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { useFocusEffect, useRouter, type Href } from 'expo-router'; +import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; -import { Card } from '@/components/card'; +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; +import type { IconName } from '@/components/icon-paths'; import { Screen } from '@/components/screen'; +import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { useDebates, type DebateSide } from '@/lib/debate-context'; +import { fetchDebateCounts, fetchRecentWork, type DebateCounts, type DebateItem, type DebateItemKind } from '@/lib/debates'; +import { formatWhen } from '@/lib/dates'; +import { mainActionGradient, radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; -/** Home. Until debates reach the app (next PR), a signed-in user sees the empty state. */ +const RECENT_LIMIT = 3; + +// The Prepare tools on the website's Current Debate page. Each opens the Prepare tab until its screen lands. +const PREPARE_TOOLS: { key: string; icon: IconName }[] = [ + { key: 'generate', icon: 'sparkles' }, + { key: 'brainstorm', icon: 'bulb' }, + { key: 'factoids', icon: 'book' }, + { key: 'flowchart', icon: 'branch' }, +]; + +const KIND_ICONS: Record = { + case: 'sparkles', + attack: 'swords', + brainstorm: 'bulb', + factoids: 'book', + chat: 'chat', + flowchart: 'branch', + crossfire: 'target', + sparring: 'swords', +}; + +const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring']); + +/** Home: the Current Debate, as in mock 01. */ export default function DebateScreen() { const { t } = useTranslation(); + const router = useRouter(); + const { status, current, oneOff, setOneOff, refresh, sideOf, setSide } = useDebates(); + + // Debates switched on the website show up when the tab comes back into view. + const focusedBefore = useRef(false); + useFocusEffect( + useCallback(() => { + if (focusedBefore.current) void refresh(); + focusedBefore.current = true; + }, [refresh]), + ); + + const openSwitcher = () => router.push('/debates'); + + if (status === 'loading') { + return ( + + + + ); + } + + if (status === 'error') { + return ( + + + + {t('mobile.debate.loadFailed')} + + void refresh()} /> + + + ); + } + + if (!current) { + return ( + + + + + {t('debate.noCurrentTitle')} + + + {t('debate.noCurrentDesc')} + + + router.push('/new-debate')} /> + + ); + } + + const side = sideOf(current.id); + + return ( + + + {oneOff ? setOneOff(false)} /> : null} + + {current.motion} + + + + options={[ + { value: 'pro', label: t('debate.side.pro') }, + { value: 'con', label: t('debate.side.con') }, + ]} + value={side} + onChange={(next) => setSide(current.id, next)} + /> + + + + ); +} + +function SwitcherPill({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [styles.pill, { backgroundColor: colors.tile }, pressed && styles.pressed]}> + + {t('nav.currentDebate')} + + + + ); +} + +function OneOffNotice({ onReturn }: { onReturn: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + + + {t('debate.oneOffStrip')} + + + + ); +} + +/** Counts, the main action, the Prepare tools and recent work for one debate. */ +function DebateDetails({ debateId, side }: { debateId: string; side: DebateSide }) { + const { t } = useTranslation(); + const router = useRouter(); + const [counts, setCounts] = useState(null); + const [recent, setRecent] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async (isCurrent: () => boolean) => { + try { + const [nextCounts, nextRecent] = await Promise.all([fetchDebateCounts(debateId), fetchRecentWork(debateId, RECENT_LIMIT)]); + if (!isCurrent()) return; + setCounts(nextCounts); + setRecent(nextRecent); + setFailed(false); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [debateId]); + + // Reload on focus, so work saved on the website shows up. + useFocusEffect( + useCallback(() => { + let active = true; + void load(() => active); + return () => { + active = false; + }; + }, [load]), + ); + + const open = (href: Href) => router.navigate(href); + + return ( + <> + + + + + + + open('/practice')} /> + + + {t('mobile.tabs.prepare')} + + + {PREPARE_TOOLS.map((tool) => ( + open('/prepare')} /> + ))} + + + + {t('mobile.debate.pickUp')} + + {failed ? ( + + + {t('mobile.debate.loadFailed')} + + void load(() => true)} /> + + ) : recent === null ? ( + + ) : recent.length === 0 ? ( + + + {t('debate.noWork')} + + + ) : ( + + {recent.map((item, index) => ( + open(PRACTICE_KINDS.has(item.kind) ? '/practice' : '/prepare')} + /> + ))} + + )} + + ); +} + +function Stat({ value, label }: { value: number | undefined; label: string }) { return ( - - - {t('debate.noCurrentTitle')} + + + {value ?? '–'} + + + {label} - - {t('debate.noCurrentDesc')} + + ); +} + +function SparCard({ side, onPress }: { side: DebateSide; onPress: () => void }) { + const { t } = useTranslation(); + return ( + [styles.spar, pressed && styles.pressed]}> + + + + + + + {t('mobile.debate.spar')} + + + {t('mobile.debate.sparLine', { + format: t('sparring.format.standard'), + side: t(side === 'pro' ? 'sparring.pro' : 'sparring.con'), + })} + + + {/* A View, so the web preview paints it above the gradient like the other children. */} + + + + + ); +} + +function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onPress: () => void }) { + const { colors } = useTheme(); + return ( + [ + styles.tile, + { backgroundColor: colors.card, borderColor: colors.border }, + pressed && styles.pressed, + ]}> + + + {label} - - - {t('mobile.debate.comingSoon')} + + ); +} + +function RecentRow({ item, first, onPress }: { item: DebateItem; first: boolean; onPress: () => void }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + return ( + [ + styles.row, + !first && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + + + {item.title || t(`debate.kinds.${item.kind}`)} - - + + {t('mobile.debate.itemMeta', { kind: t(`debate.kinds.${item.kind}`), when: formatWhen(item.createdAt) })} + + + + ); } const styles = StyleSheet.create({ - title: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6, marginTop: 8 }, + loading: { marginTop: 32 }, body: { fontSize: 16, lineHeight: 22 }, + pressed: { opacity: 0.7 }, + pill: { + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: 5, + borderRadius: radius.pill, + paddingLeft: 14, + paddingRight: 11, + minHeight: 34, + marginTop: 4, + }, + pillLabel: { fontSize: 15 }, + notice: { + marginTop: 12, + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: radius.field, + paddingHorizontal: 14, + paddingTop: 10, + alignItems: 'flex-start', + }, + noticeText: { fontSize: 14, lineHeight: 19 }, + emptyText: { gap: 8 }, + emptyTitle: { fontSize: 28, lineHeight: 34, letterSpacing: -0.6 }, + motion: { fontSize: 27, lineHeight: 32, letterSpacing: -0.6, marginTop: 12, marginBottom: 15 }, + stats: { flexDirection: 'row', gap: 24, marginTop: 13, marginBottom: 15, marginHorizontal: 2 }, + statValue: { fontSize: 21, lineHeight: 26, letterSpacing: -0.2 }, + statLabel: { fontSize: 15 }, + spar: { + borderRadius: radius.card + 2, + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + gap: 14, + paddingVertical: 17, + paddingHorizontal: 17, + }, + sparIcon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: 'rgba(255,255,255,0.2)', + alignItems: 'center', + justifyContent: 'center', + }, + sparText: { flex: 1, gap: 2 }, + sparTitle: { color: '#ffffff', fontSize: 18, lineHeight: 23 }, + sparLine: { color: 'rgba(255,255,255,0.88)', fontSize: 14.5, lineHeight: 20 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 20, marginBottom: 9, marginHorizontal: 2 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + tile: { + flexGrow: 1, + flexBasis: '45%', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderWidth: 1, + borderRadius: radius.card, + paddingVertical: 11, + paddingHorizontal: 12, + }, + tileLabel: { flex: 1, fontSize: 16 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, + rowText: { flex: 1, gap: 2 }, + rowTitle: { fontSize: 16 }, + rowMeta: { fontSize: 14 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index aa5473f42..32d7a2a0c 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,6 +2,7 @@ import '@/lib/i18n'; import { useFonts } from 'expo-font'; import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router'; +import type { NativeStackNavigationOptions } from 'expo-router/native-stack'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { useEffect } from 'react'; @@ -41,6 +42,15 @@ function Root() { if (!ready) return null; + // Native form sheets with a grabber on iOS. The web preview shows them as full pages. + const sheet: NativeStackNavigationOptions = { + presentation: 'formSheet', + sheetGrabberVisible: true, + sheetAllowedDetents: [0.86, 1], + sheetCornerRadius: 22, + contentStyle: { backgroundColor: colors.background }, + }; + const base = scheme === 'dark' ? DarkTheme : DefaultTheme; const navigationTheme = { ...base, @@ -62,6 +72,8 @@ function Root() { + + diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx new file mode 100644 index 000000000..3ce82104d --- /dev/null +++ b/apps/mobile/src/app/debates.tsx @@ -0,0 +1,224 @@ +import { useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Switch, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Icon } from '@/components/icon'; +import { Text } from '@/components/text'; +import { useDebates } from '@/lib/debate-context'; +import { fetchSourceCounts, type Debate } from '@/lib/debates'; +import { formatRelativeDate, isToday } from '@/lib/dates'; +import { radius } from '@/theme/colors'; +import { fontFamily } from '@/theme/fonts'; +import { useTheme } from '@/theme/use-theme'; + +function normalize(text: string) { + return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase(); +} + +/** "Your debates": switch the Current Debate, turn on one-off mode, or start a new one (mock 02). */ +export default function DebatesSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); + const [query, setQuery] = useState(''); + const [sourceCounts, setSourceCounts] = useState>({}); + const [switching, setSwitching] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const ids = debates.map((debate) => debate.id).join(','); + useEffect(() => { + if (!ids) return; + let active = true; + fetchSourceCounts(ids.split(',')) + .then((counts) => { + if (active) setSourceCounts(counts); + }) + .catch((cause: unknown) => console.warn('Could not count sources:', cause)); + return () => { + active = false; + }; + }, [ids]); + + const visible = useMemo(() => { + const needle = normalize(query); + return needle ? debates.filter((debate) => normalize(debate.motion).includes(needle)) : debates; + }, [debates, query]); + + const pick = async (debate: Debate) => { + if (switching) return; + if (debate.id === current?.id && !oneOff) { + router.back(); + return; + } + setSwitching(debate.id); + setError(''); + try { + await switchTo(debate.id); + router.back(); + } catch { + setError(t('debate.switchFailed')); + } finally { + setSwitching(null); + } + }; + + return ( + + + + {t('debate.yourDebates')} + + router.back()} /> + + + + + + + + {status === 'loading' ? : null} + {status === 'error' ? ( + + + {t('mobile.debates.loadFailed')} + + void refresh()} /> + + ) : null} + {status === 'ready' && debates.length === 0 ? ( + + {t('debate.noOtherDebates')} + + ) : null} + {status === 'ready' && debates.length > 0 && visible.length === 0 ? ( + + {t('mobile.debates.noMatches')} + + ) : null} + {visible.length > 0 ? ( + + {visible.map((debate, index) => { + const isCurrent = debate.id === current?.id; + const sources = sourceCounts[debate.id]; + const when = isToday(debate.lastActivityAt) + ? t('mobile.debates.activeToday') + : formatRelativeDate(debate.lastActivityAt); + return ( + void pick(debate)} + style={({ pressed }) => [ + styles.row, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + + {debate.motion} + + + {sources === undefined + ? when + : t('mobile.debates.meta', { sources: t('mobile.debates.sourceCount', { count: sources }), when })} + + + {switching === debate.id ? ( + + ) : isCurrent ? ( + + ) : null} + + ); + })} + + ) : null} + + + + + + + + + {t('mobile.debates.oneOff')} + + + {t('mobile.debates.oneOffDesc')} + + + + + + + {error ? ( + + {error} + + ) : null} + + router.push('/new-debate')} /> + + ); +} + +const styles = StyleSheet.create({ + content: { paddingHorizontal: 20, paddingTop: 22, gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + search: { + flexDirection: 'row', + alignItems: 'center', + gap: 9, + borderRadius: radius.field, + paddingHorizontal: 13, + minHeight: 46, + }, + searchInput: { flex: 1, fontSize: 16.5, fontFamily: fontFamily.regular, paddingVertical: 10 }, + loading: { marginVertical: 24 }, + body: { fontSize: 15, lineHeight: 21 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 16 }, + rowText: { flex: 1, gap: 3 }, + motion: { fontSize: 16.5, lineHeight: 22 }, + meta: { fontSize: 14, lineHeight: 19 }, + pressed: { opacity: 0.6 }, + oneOffCard: { padding: 0, gap: 0 }, + oneOff: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 13, paddingHorizontal: 16 }, + oneOffIcon: { width: 42, height: 42, borderRadius: radius.tile, alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx new file mode 100644 index 000000000..a3d0ea410 --- /dev/null +++ b/apps/mobile/src/app/new-debate.tsx @@ -0,0 +1,151 @@ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { MainActionButton, TextButton } from '@/components/buttons'; +import { Card } from '@/components/card'; +import { Field } from '@/components/field'; +import { Icon } from '@/components/icon'; +import { SegmentedControl } from '@/components/segmented-control'; +import { Text } from '@/components/text'; +import { useDebates, type DebateSide } from '@/lib/debate-context'; +import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; +import { useTheme } from '@/theme/use-theme'; + +/** Start a debate: motion, side and format, like the website's Start new debate dialog. It becomes current. */ +export default function NewDebateSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { create } = useDebates(); + const [motion, setMotion] = useState(''); + const [side, setSide] = useState('pro'); + const [format, setFormat] = useState('custom'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const submit = async () => { + // The button stays disabled until there is a motion, as on the website. + if (!motion.trim()) return; + setSaving(true); + setError(''); + try { + // The website's form saves the role too; it has no role picker here yet, so it keeps the default. + await create({ motion, settings: { format, role: 'general' }, side }); + // Closes this sheet and the switcher under it, if it was opened from there. + router.dismissAll(); + } catch (cause) { + console.warn('Could not create the debate:', cause instanceof Error ? cause.message : cause); + setError(t('debate.saveFailed')); + setSaving(false); + } + }; + + return ( + + + + + {t('debate.startNew')} + + router.back()} disabled={saving} /> + + + {t('debate.startNewDesc')} + + + + + + + {t('mobile.newDebate.side')} + + + options={[ + { value: 'pro', label: t('debate.side.pro') }, + { value: 'con', label: t('debate.side.con') }, + ]} + value={side} + onChange={setSide} + disabled={saving} + /> + + + + + {t('debate.format')} + + + {DEBATE_FORMATS.map((value, index) => { + const selected = value === format; + return ( + setFormat(value)} + style={({ pressed }) => [ + styles.option, + index > 0 && { borderTopWidth: 1, borderTopColor: colors.line }, + pressed && styles.pressed, + ]}> + + {t(`debate.formats.${value}`)} + + {selected ? : null} + + ); + })} + + + + {error ? ( + + {error} + + ) : null} + + void submit()} + /> + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + motion: { minHeight: 96, paddingTop: 13, paddingBottom: 13, textAlignVertical: 'top' }, + group: { gap: 7 }, + label: { fontSize: 14, marginLeft: 4 }, + list: { padding: 0, gap: 0 }, + option: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 16, gap: 12 }, + optionLabel: { flex: 1, fontSize: 16 }, + pressed: { opacity: 0.6 }, +}); diff --git a/apps/mobile/src/components/buttons.tsx b/apps/mobile/src/components/buttons.tsx index 265a2667a..6dd81b607 100644 --- a/apps/mobile/src/components/buttons.tsx +++ b/apps/mobile/src/components/buttons.tsx @@ -1,6 +1,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { ActivityIndicator, Pressable, StyleSheet } from 'react-native'; +import { Icon } from './icon'; +import type { IconName } from './icon-paths'; import { Text } from './text'; import { mainActionGradient, radius } from '@/theme/colors'; @@ -35,6 +37,44 @@ export function MainActionButton({ ); } +/** A solid button for a screen's action when the gradient is taken or would be too loud, as in the mocks' sheets. */ +export function PrimaryButton({ + label, + icon, + onPress, + busy, + disabled, + destructive, +}: { + label: string; + icon?: IconName; + onPress: () => void; + busy?: boolean; + disabled?: boolean; + destructive?: boolean; +}) { + const { colors } = useTheme(); + const inactive = disabled || busy; + const foreground = destructive ? '#ffffff' : colors.onPrimary; + return ( + [ + styles.primary, + { backgroundColor: destructive ? colors.destructive : colors.primary }, + (pressed || inactive) && styles.dimmed, + ]}> + {busy ? : icon ? : null} + + {label} + + + ); +} + /** A secondary action shown as tinted text, like an iOS plain button. */ export function TextButton({ label, @@ -75,6 +115,16 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, }, mainLabel: { color: '#ffffff', fontSize: 17 }, + primary: { + minHeight: 52, + borderRadius: radius.card - 2, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingHorizontal: 20, + }, + primaryLabel: { fontSize: 17 }, dimmed: { opacity: 0.6 }, text: { minHeight: 44, alignItems: 'center', justifyContent: 'center' }, textLabel: { fontSize: 16 }, diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index 0b8bee6bf..b49766ec8 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -12,6 +12,16 @@ export const iconPaths = { library: '', user: '', globe: '', + bulb: '', + book: '', + branch: '', + chat: '', + chevronRight: '', + chevronDown: '', + plus: '', + search: '', + check: '', + x: '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/icon.tsx b/apps/mobile/src/components/icon.tsx index bc9b1bac4..e8cd65dc5 100644 --- a/apps/mobile/src/components/icon.tsx +++ b/apps/mobile/src/components/icon.tsx @@ -3,5 +3,6 @@ import { SvgXml } from 'react-native-svg'; import { iconSvg, type IconName } from './icon-paths'; export function Icon({ name, color, size = 22 }: { name: IconName; color: string; size?: number }) { - return ; + // Icons keep their size next to text that wraps. + return ; } diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index 5870d1d24..e747b1e27 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -6,12 +6,12 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; * A tab's scrolling content. On iOS the native header and tab bar adjust the * insets automatically; `safeTop` is for screens without a header. */ -export function Screen({ children, safeTop }: { children: ReactNode; safeTop?: boolean }) { +export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { const insets = useSafeAreaInsets(); return ( + contentContainerStyle={[styles.content, safeTop && { paddingTop: insets.top + 12 }, gap !== undefined && { gap }]}> {children} ); diff --git a/apps/mobile/src/lib/dates.ts b/apps/mobile/src/lib/dates.ts new file mode 100644 index 000000000..98ea19c9a --- /dev/null +++ b/apps/mobile/src/lib/dates.ts @@ -0,0 +1,30 @@ +import { isAppLanguage, toIntlLocale, type AppLanguage } from '@debatelab/core/languages'; +import { formatDistanceToNow, formatRelative, isToday } from 'date-fns'; +import { enGB, es, hu } from 'date-fns/locale'; + +import i18n from './i18n'; + +const LOCALES = { en: enGB, es, hu } as const; + +function language(): AppLanguage { + return isAppLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en'; +} + +/** "3 weeks ago", as the website's formatRelativeDate. */ +export function formatRelativeDate(value: number): string { + return formatDistanceToNow(value, { addSuffix: true, locale: LOCALES[language()] }); +} + +/** "today at 16:43", "yesterday at 09:10", then a date. */ +export function formatWhen(value: number): string { + return formatRelative(value, Date.now(), { locale: LOCALES[language()] }); +} + +export { isToday }; + +/** A day like 31/12/2026, as the website's formatEuropeanDate. */ +export function formatDate(value: string): string { + const date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00` : value); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat(toIntlLocale(language()), { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date); +} diff --git a/package-lock.json b/package-lock.json index b720a43b4..ca545ac95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,6 +123,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "@supabase/supabase-js": "^2.49.1", "aes-js": "^3.1.2", + "date-fns": "^3.6.0", "expo": "~57.0.27", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.3", diff --git a/src/locales/en.json b/src/locales/en.json index 678cac756..4e303aeb7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2411,7 +2411,41 @@ "body": "This part of DebateLab is on its way to the app. Until then, use it on the website." }, "debate": { - "comingSoon": "Starting and switching debates comes to the app in the next update." + "statSources_one": "source", + "statSources_other": "sources", + "statResults_one": "result", + "statResults_other": "results", + "statRounds_one": "round", + "statRounds_other": "rounds", + "spar": "Spar against the AI", + "sparLine": "{{format}}, you speak {{side}}", + "pickUp": "Pick up where you left off", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "Could not load your debate.", + "sideAria": "Your side" + }, + "common": { + "done": "Done" + }, + "debates": { + "search": "Search motions", + "noMatches": "No debates match your search.", + "sourceCount_one": "{{count}} source", + "sourceCount_other": "{{count}} sources", + "activeToday": "active today", + "meta": "{{sources}}, {{when}}", + "oneOff": "One-off mode", + "oneOffDesc": "Use a tool without saving to a debate", + "newDebate": "New debate", + "loadFailed": "Could not load your debates." + }, + "newDebate": { + "side": "Your side" + }, + "you": { + "usage": "{{used}} of {{limit}} AI uses today", + "loadFailed": "Could not load your subscription.", + "saveFailed": "Could not save your settings. Try again." } } } diff --git a/src/locales/es.json b/src/locales/es.json index 4bad9f8e4..90af8cf0c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2411,7 +2411,41 @@ "body": "Esta parte de DebateLab llegará pronto a la app. Mientras tanto, úsala en la web." }, "debate": { - "comingSoon": "Podrás iniciar y cambiar de debate en la app con la próxima actualización." + "statSources_one": "fuente", + "statSources_other": "fuentes", + "statResults_one": "resultado", + "statResults_other": "resultados", + "statRounds_one": "ronda", + "statRounds_other": "rondas", + "spar": "Entrena contra la IA", + "sparLine": "{{format}}, tu postura: {{side}}", + "pickUp": "Continúa donde lo dejaste", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "No se pudo cargar tu debate.", + "sideAria": "Tu postura" + }, + "common": { + "done": "Listo" + }, + "debates": { + "search": "Buscar mociones", + "noMatches": "Ningún debate coincide con tu búsqueda.", + "sourceCount_one": "{{count}} fuente", + "sourceCount_other": "{{count}} fuentes", + "activeToday": "activo hoy", + "meta": "{{sources}}, {{when}}", + "oneOff": "Modo puntual", + "oneOffDesc": "Usa una herramienta sin guardar en un debate", + "newDebate": "Nuevo debate", + "loadFailed": "No se pudieron cargar tus debates." + }, + "newDebate": { + "side": "Tu postura" + }, + "you": { + "usage": "{{used}} de {{limit}} usos de IA hoy", + "loadFailed": "No se pudo cargar tu suscripción.", + "saveFailed": "No se pudieron guardar tus ajustes. Inténtalo de nuevo." } } } diff --git a/src/locales/hu.json b/src/locales/hu.json index a5a9b0c03..1a81ec002 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2411,7 +2411,41 @@ "body": "A DebateLab ezen része hamarosan az appba is megérkezik. Addig a weboldalon használhatod." }, "debate": { - "comingSoon": "Vitát indítani és váltani a következő frissítéssel tudsz majd az appban." + "statSources_one": "forrás", + "statSources_other": "forrás", + "statResults_one": "eredmény", + "statResults_other": "eredmény", + "statRounds_one": "forduló", + "statRounds_other": "forduló", + "spar": "Gyakorolj az MI ellen", + "sparLine": "{{format}}, a te oldalad: {{side}}", + "pickUp": "Folytasd, ahol abbahagytad", + "itemMeta": "{{kind}}, {{when}}", + "loadFailed": "Nem sikerült betölteni a vitát.", + "sideAria": "A te oldalad" + }, + "common": { + "done": "Kész" + }, + "debates": { + "search": "Tételek keresése", + "noMatches": "Nincs a keresésnek megfelelő vita.", + "sourceCount_one": "{{count}} forrás", + "sourceCount_other": "{{count}} forrás", + "activeToday": "ma aktív", + "meta": "{{sources}}, {{when}}", + "oneOff": "Egyszeri mód", + "oneOffDesc": "Eszköz használata vitába mentés nélkül", + "newDebate": "Új vita", + "loadFailed": "Nem sikerült betölteni a vitáidat." + }, + "newDebate": { + "side": "A te oldalad" + }, + "you": { + "usage": "Ma {{used}} / {{limit}} MI-használat", + "loadFailed": "Nem sikerült betölteni az előfizetésedet.", + "saveFailed": "Nem sikerült menteni a beállításokat. Próbáld újra." } } } From b792fe1753dd1c115da745dbad0638eb1e3e6f40 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:18:18 +0000 Subject: [PATCH 03/12] Add the You tab: plan, shared settings and account deletion You shows the plan and today's AI usage, computed as the website's useSubscription does; the interface language, AI output language and theme, written to user_settings so the website shows the same; and the account, with deletion through the account Edge Function behind the website's typed confirmation. Download my data is left for later, since it needs file sharing. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/app/(tabs)/(you)/you.tsx | 227 +++++++++++++++++++++-- apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/delete-account.tsx | 112 +++++++++++ apps/mobile/src/lib/account.ts | 24 +++ apps/mobile/src/lib/subscription.ts | 84 +++++++++ 5 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/src/app/delete-account.tsx create mode 100644 apps/mobile/src/lib/account.ts create mode 100644 apps/mobile/src/lib/subscription.ts diff --git a/apps/mobile/src/app/(tabs)/(you)/you.tsx b/apps/mobile/src/app/(tabs)/(you)/you.tsx index 286ba8de4..27d4238c4 100644 --- a/apps/mobile/src/app/(tabs)/(you)/you.tsx +++ b/apps/mobile/src/app/(tabs)/(you)/you.tsx @@ -1,60 +1,247 @@ +import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View } from 'react-native'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; import { TextButton } from '@/components/buttons'; import { Card, IconTile } from '@/components/card'; +import { Icon } from '@/components/icon'; import { ScreenTitle } from '@/components/screen-title'; import { Screen } from '@/components/screen'; +import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; +import { formatDate } from '@/lib/dates'; +import { useSettings, type ThemePreference } from '@/lib/settings'; +import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription'; import { supabaseEnv } from '@/lib/supabase-config'; import { useTheme } from '@/theme/use-theme'; -/** Account and sign-out only. Settings and account deletion arrive with the You tab's own PR. */ +const TIER_LABELS: Record = { + trial: 'settings.tierTrial', + pro: 'settings.tierPro', + legacy_free: 'settings.tierLegacyFree', + expired: 'settings.tierExpired', +}; + +const LANGUAGE_OPTIONS = (Object.keys(APP_LANGUAGES) as AppLanguage[]).map((value) => ({ + value, + label: APP_LANGUAGES[value].label, +})); + +/** Plan, settings shared with the website, and the account. Download my data is not in the app yet. */ export default function YouScreen() { const { t } = useTranslation(); + const router = useRouter(); const { colors } = useTheme(); const { session, signOut } = useAuth(); + const settings = useSettings(); + const [saveFailed, setSaveFailed] = useState(false); + + const save = (change: () => Promise) => { + setSaveFailed(false); + change().catch((cause: unknown) => { + console.warn('Could not save settings:', cause instanceof Error ? cause.message : cause); + setSaveFailed(true); + }); + }; return ( + + + + + {t('settings.globalSettings')} + - + + save(() => settings.setLanguage(value))} + /> + + + save(() => settings.setOutputLanguage(value))} + /> + + + + options={[ + { value: 'light', label: t('settings.light') }, + { value: 'dark', label: t('settings.dark') }, + { value: 'system', label: t('settings.system') }, + ]} + value={settings.theme} + onChange={(value) => save(() => settings.setTheme(value))} + /> + + {saveFailed ? ( + + {t('mobile.you.saveFailed')} + + ) : null} + + + + {t('settings.account')} + + + - - + + {t('settings.signedInAs')} {session?.user.email ?? ''} + {supabaseEnv === 'staging' ? ( + + + {t('settings.stagingBanner')} + + + ) : null} - {supabaseEnv === 'staging' ? ( - - - {t('settings.stagingBanner')} + router.push('/delete-account')} + style={({ pressed }) => [styles.row, { borderTopWidth: 1, borderTopColor: colors.line }, pressed && styles.pressed]}> + + + {t('settings.deleteAccount')} + + + {t('settings.deleteAccountDesc')} - ) : null} - - - - {t('mobile.placeholder.body')} - + + + void signOut()} /> ); } +function Setting({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function SubscriptionCard({ userId }: { userId: string | undefined }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [subscription, setSubscription] = useState(null); + const [failed, setFailed] = useState(false); + + const load = useCallback(async (isCurrent: () => boolean) => { + if (!userId) return; + try { + const next = await fetchSubscription(userId); + if (!isCurrent()) return; + setSubscription(next); + setFailed(false); + } catch (cause) { + if (!isCurrent()) return; + console.warn('Could not load the subscription:', cause instanceof Error ? cause.message : cause); + setFailed(true); + } + }, [userId]); + + // Usage changes as the student works, so read it again whenever You comes into view. + useFocusEffect( + useCallback(() => { + let active = true; + void load(() => active); + return () => { + active = false; + }; + }, [load]), + ); + + return ( + + + + + {t('settings.subscription')} + + {subscription ? ( + + + {t(TIER_LABELS[subscription.tier])} + + + ) : null} + + {failed && !subscription ? ( + <> + + {t('mobile.you.loadFailed')} + + void load(() => true)} /> + + ) : !subscription ? ( + + + + {t('settings.loading')} + + + ) : ( + + {subscription.tier === 'trial' && subscription.trialEndsAt ? ( + + {t('settings.ends')} {formatDate(subscription.trialEndsAt)} + + ) : null} + + {t('mobile.you.usage', { used: subscription.dailyUsed, limit: subscription.dailyLimit })} + + + {t(subscription.tier === 'expired' ? 'settings.accessExpired' : 'settings.accessActive')} + + + )} + + ); +} + const styles = StyleSheet.create({ - row: { flexDirection: 'row', alignItems: 'center', gap: 12 }, - account: { flex: 1, gap: 2 }, - caption: { fontSize: 13 }, - email: { fontSize: 17 }, + section: { fontSize: 18.5, lineHeight: 24, letterSpacing: -0.2, marginTop: 6, marginBottom: -6, marginHorizontal: 2 }, + small: { fontSize: 14, lineHeight: 19 }, + planHeader: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + planTitle: { flex: 1, fontSize: 17 }, + planBody: { gap: 4 }, + usage: { fontSize: 16 }, + loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 }, badgeText: { fontSize: 13 }, - body: { fontSize: 15, lineHeight: 21 }, + setting: { gap: 7 }, + settingLabel: { fontSize: 14, marginLeft: 2 }, + list: { padding: 0, gap: 0 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14, paddingHorizontal: 16 }, + account: { alignItems: 'flex-start' }, + rowText: { flex: 1, gap: 3 }, + rowTitle: { fontSize: 16 }, + email: { fontSize: 17 }, + pressed: { opacity: 0.6 }, }); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 32d7a2a0c..311548c0c 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -74,6 +74,7 @@ function Root() { + diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx new file mode 100644 index 000000000..472520d76 --- /dev/null +++ b/apps/mobile/src/app/delete-account.tsx @@ -0,0 +1,112 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { PrimaryButton, TextButton } from '@/components/buttons'; +import { Field } from '@/components/field'; +import { Text } from '@/components/text'; +import { deleteAccount, deletionConfirmation } from '@/lib/account'; +import { useAuth } from '@/lib/auth'; +import { useTheme } from '@/theme/use-theme'; + +/** Removes what this device kept for the account: its settings and debate sides. */ +async function forgetAccount(userId: string) { + const keys = await AsyncStorage.getAllKeys(); + await AsyncStorage.multiRemove(keys.filter((key) => key.includes(userId))); +} + +/** Deletes the account after the same typed confirmation as the website (rule 5.1.1(v)). */ +export default function DeleteAccountSheet() { + const { t } = useTranslation(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const { session, signOut } = useAuth(); + const email = session?.user.email; + const expected = deletionConfirmation(email); + const [confirmation, setConfirmation] = useState(''); + const [deleting, setDeleting] = useState(false); + // null while nothing failed; the server's message, possibly empty, after a failure. + const [error, setError] = useState(null); + + const remove = async () => { + if (confirmation !== expected || deleting) return; + const userId = session?.user.id; + setDeleting(true); + setError(null); + try { + await deleteAccount(confirmation); + } catch (cause) { + setDeleting(false); + setError(cause instanceof Error ? cause.message : ''); + return; + } + if (userId) await forgetAccount(userId).catch(() => undefined); + // The account is gone, so the server sign-out may fail; signOut still ends the session here. + await signOut(); + }; + + return ( + + + + + {t('settings.deleteAccountDialogTitle')} + + router.back()} disabled={deleting} /> + + + {t('settings.deleteAccountDialogDesc', { email: email || t('settings.unknownUser') })} + + void remove()} + /> + {error !== null ? ( + + + {t('settings.deleteAccountFailed')} + + {error ? ( + + {error} + + ) : null} + + ) : null} + void remove()} + /> + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1 }, + content: { paddingHorizontal: 20, paddingTop: 22, gap: 18, width: '100%', maxWidth: 640, alignSelf: 'center' }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }, + title: { fontSize: 21, lineHeight: 26, letterSpacing: -0.3, flexShrink: 1 }, + body: { fontSize: 15, lineHeight: 21 }, + error: { gap: 2 }, +}); diff --git a/apps/mobile/src/lib/account.ts b/apps/mobile/src/lib/account.ts new file mode 100644 index 000000000..c1d582933 --- /dev/null +++ b/apps/mobile/src/lib/account.ts @@ -0,0 +1,24 @@ +import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; + +import { supabase } from './supabase'; + +/** The text a user types to confirm deletion. Must match deletionConfirmation in supabase/functions/account. */ +export function deletionConfirmation(email: string | undefined): string { + return email || 'DELETE'; +} + +/** + * Permanently deletes the signed-in account through the `account` Edge + * Function, with the same request as the website's src/lib/account.ts. Any + * Stripe subscription is canceled first. + */ +export async function deleteAccount(confirmation: string): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) throw new Error('Please sign in first'); + const { error } = await supabase.functions.invoke('account', { + headers: { Authorization: `Bearer ${accessToken}` }, + body: { action: 'delete', confirmation }, + }); + if (error) throw new Error((await getEdgeFunctionErrorMessage(error)) || 'Could not delete your account'); +} diff --git a/apps/mobile/src/lib/subscription.ts b/apps/mobile/src/lib/subscription.ts new file mode 100644 index 000000000..886d4b6ae --- /dev/null +++ b/apps/mobile/src/lib/subscription.ts @@ -0,0 +1,84 @@ +import { supabase } from './supabase'; + +/** + * The plan and today's AI usage, computed exactly as the website's + * useSubscription (src/hooks/use-subscription.ts) does. That hook is not + * shared yet because it changes with in-app purchase. + */ +export type SubscriptionTier = 'trial' | 'pro' | 'legacy_free' | 'expired'; + +export interface Subscription { + tier: SubscriptionTier; + dailyLimit: number; + dailyUsed: number; + trialEndsAt: string | null; +} + +interface BillingRow { + status?: string | null; + current_period_end?: string | null; +} + +export async function fetchSubscription(userId: string): Promise { + const snapshot: Subscription = { tier: 'expired', dailyLimit: 0, dailyUsed: 0, trialEndsAt: null }; + + const { data, error: settingsError } = await supabase + .from('user_settings') + .select('subscription_tier, daily_limit_override, trial_ends_at, grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (settingsError) throw new Error(settingsError.message); + + if (data) { + const rawTier = (data.subscription_tier || 'trial') as SubscriptionTier; + const limitOrDefault = (defaultLimit: number) => (data.daily_limit_override as number | null) ?? defaultLimit; + const now = new Date(); + + const { data: billingData, error: billingError } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId) + .order('current_period_end', { ascending: false, nullsFirst: false }) + .limit(5); + if (billingError) console.warn('Could not load billing state:', billingError.message); + const billingRows: BillingRow[] = billingError ? [] : billingData ?? []; + + const activeStripeSubscription = billingRows.some((row) => + (row.status === 'active' || row.status === 'trialing') && + row.current_period_end && + new Date(row.current_period_end) > now, + ); + const promoEnd = data.grace_period_ends_at ? new Date(data.grace_period_ends_at as string) : null; + const trialEnd = data.trial_ends_at ? new Date(data.trial_ends_at as string) : null; + const hasActiveTrialGift = Boolean(trialEnd && now < trialEnd); + snapshot.trialEndsAt = (data.trial_ends_at as string | null) ?? null; + + if (rawTier === 'legacy_free') { + snapshot.tier = promoEnd && now < promoEnd ? 'legacy_free' : hasActiveTrialGift ? 'trial' : 'expired'; + snapshot.dailyLimit = limitOrDefault(snapshot.tier === 'legacy_free' ? 50 : snapshot.tier === 'trial' ? 5 : 0); + } else if (rawTier === 'pro' && ((promoEnd && now < promoEnd) || activeStripeSubscription)) { + snapshot.tier = 'pro'; + snapshot.dailyLimit = limitOrDefault(50); + } else if (rawTier === 'trial' && trialEnd && now < trialEnd) { + snapshot.tier = 'trial'; + snapshot.dailyLimit = limitOrDefault(5); + } else if (hasActiveTrialGift) { + snapshot.tier = 'trial'; + snapshot.dailyLimit = limitOrDefault(5); + } else { + snapshot.dailyLimit = limitOrDefault(0); + } + } + + // Usage resets at midnight UTC. + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const { count, error: usageError } = await supabase + .from('ai_requests') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .gte('created_at', todayStart.toISOString()); + if (usageError) console.warn('Could not load daily usage:', usageError.message); + + return { ...snapshot, dailyUsed: usageError ? 0 : count || 0 }; +} From 290fe152755d2bb253236fc26591b87e1fcc7a2c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:18:18 +0000 Subject: [PATCH 04/12] Screenshot the new mobile screens and document the data layer The screenshot script now captures the Debate tab, the switcher, the new-debate form, the empty state and You. It answers the app's user_settings reads with each run's language and the System theme, so the account's saved choices do not override the locale under test. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/AGENTS.md | 5 +- apps/mobile/scripts/screenshots.ts | 84 +++++++++++++++++++++++++++++- docs/development.md | 4 +- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0c4f474ac..71ccd1a42 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -16,9 +16,10 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp ## Layout -- `src/app/`: routes. `sign-in.tsx`, and `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You. +- `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, declared in the root `_layout.tsx`. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. -- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. +- `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. +- One-off mode is session-only and never written, as on the website. The side shown on the Debate tab is kept on the device per account and debate, because `debates` has no side column and the website drops unknown keys from a debate's `settings`. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index df7c13f22..258bf902c 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -7,12 +7,21 @@ * npm run -w @debatelab/mobile export:web * PORT= npx tsx apps/mobile/scripts/screenshots.ts * + * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a * cold start without a network keeps the user signed in. The deployed Edge * Functions only accept browser calls from a few origins, so for that run use * PORT=18080 (http://127.0.0.1:18080). Never print the code or put it in a * screenshot. + * + * Signed in, the app applies the account's saved language and theme, which + * would override the locale and colour scheme under test. Screenshot runs + * therefore answer the app's user_settings reads with the run's language and + * the System theme; nothing is written. The Debate tab shows the account's + * real Current Debate, and its empty state comes from answering the + * current_debates read with no row. */ import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -28,6 +37,7 @@ const DIST = join(__dirname, '../dist'); const OUT = join(__dirname, '../../../.verify/mobile-shots'); const PORT = Number(process.env.PORT); const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim(); +const ONLY = process.env.ONLY?.trim() ?? ''; const ORIGIN = `http://127.0.0.1:${PORT}`; const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host; const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`; @@ -38,7 +48,8 @@ const DEVICES = { } as const; const SCHEMES = ['light', 'dark'] as const; const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const; -const SIGNED_IN_ROUTES = { debate: '/', prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', library: '/library', you: '/you' }; +const REST = `https://${STAGING_HOST}/rest/v1`; const TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -83,6 +94,61 @@ async function shoot(page: Page, name: string) { await page.screenshot({ path: join(OUT, `${name}.png`), mask: [page.getByTestId('account-email')] }); } +/** Answers user_settings reads with the run's language and the System theme (see the header). */ +async function pinSettings(page: Page, language: string) { + await page.route(`${REST}/user_settings*`, async (route) => { + if (route.request().method() !== 'GET') return route.continue(); + const response = await route.fetch(); + const pin = (row: Record) => ({ + ...row, + ...('language' in row ? { language } : {}), + ...('output_language' in row ? { output_language: language } : {}), + ...('theme' in row ? { theme: 'system' } : {}), + }); + const body: unknown = await response.json(); + const json = Array.isArray(body) + ? body.map((row) => pin(row as Record)) + : body && typeof body === 'object' ? pin(body as Record) : body; + await route.fulfill({ response, json }); + }); +} + +/** Waits until the screen's data has arrived, so no spinner is caught mid-load. */ +async function settle(page: Page) { + await page.waitForLoadState('networkidle'); + await page.waitForFunction(() => document.querySelectorAll('[role="progressbar"]').length === 0, undefined, { timeout: 15_000 }); +} + +async function shootDebateScreens(page: Page, prefix: string) { + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + await shoot(page, `${prefix}-debate`); + + await page.getByTestId('current-debate-pill').click(); + await page.getByTestId('debates-sheet').waitFor(); + await settle(page); + // Let the sheet finish sliding in. + await page.waitForTimeout(700); + await shoot(page, `${prefix}-debates-sheet`); + + await page.getByRole('button', { name: /^(New debate|Új vita|Nuevo debate)$/ }).click(); + await page.getByTestId('new-debate-sheet').waitFor(); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-new-debate`); +} + +async function shootEmptyDebate(page: Page, prefix: string) { + await page.route(`${REST}/current_debates*`, (route) => + route.fulfill({ status: 200, contentType: 'application/json', json: [] }), + ); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-pill').waitFor({ timeout: 30_000 }); + await settle(page); + await shoot(page, `${prefix}-debate-empty`); +} + async function signIn(browser: Browser, problems: string[]): Promise { const context = await browser.newContext({ ...DEVICES.iphone, locale: LOCALES.en }); const page = await context.newPage(); @@ -171,6 +237,7 @@ async function main() { for (const scheme of SCHEMES) { for (const [language, locale] of Object.entries(LOCALES)) { const prefix = `${device}-${scheme}-${language}`; + if (!prefix.startsWith(ONLY)) continue; const signedOut = await browser.newContext({ ...options, colorScheme: scheme, locale }); const page = await signedOut.newPage(); watchErrors(page, problems, prefix); @@ -189,12 +256,25 @@ async function main() { const signedIn = await browser.newContext({ ...options, colorScheme: scheme, locale, storageState: session }); const tabPage = await signedIn.newPage(); watchErrors(tabPage, problems, `${prefix} signed in`); + await pinSettings(tabPage, language); + await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => { + await shoot(tabPage, `${prefix}-failed`); + throw error; + }); + shots += 3; for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { await tabPage.goto(`${ORIGIN}${route}`); - await tabPage.getByRole('tablist').waitFor(); + await tabPage.getByRole('tablist').first().waitFor(); + if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 }); + await settle(tabPage); await shoot(tabPage, `${prefix}-${name}`); shots += 1; } + const emptyPage = await signedIn.newPage(); + watchErrors(emptyPage, problems, `${prefix} empty`); + await pinSettings(emptyPage, language); + await shootEmptyDebate(emptyPage, prefix); + shots += 1; await signedIn.close(); } } diff --git a/docs/development.md b/docs/development.md index 862f6bff3..72fc8b40d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -101,14 +101,14 @@ The iOS app lives in [`apps/mobile/`](../apps/mobile/AGENTS.md). The root `npm c ### Screenshots on the VPS -The web export renders the app's screens in Chromium. It has no native tab bar, large titles, sheets or gestures, so it shows layout, copy and colour, not iOS behaviour. +The web export renders the app's screens in Chromium. It has no native tab bar, large titles or gestures, and it draws the form sheets as plain full-screen pages, so it shows layout, copy and colour, not iOS behaviour. ```bash npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts ``` -Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. +Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs, the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. ### Expo Go on Adam's iPhone From 4391026f2889fdf98d631f02842db3aaf043a5d1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:26:26 +0000 Subject: [PATCH 05/12] Fit long tool labels and show the one-off icon in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hungarian "Folyamatábra" did not fit its Prepare tile, so the tiles are tighter and the label shrinks to fit on iOS. The one-off icon tile used the segment colour, which matches the card in dark mode; it now uses the line colour, as in mock 02. Co-Authored-By: Claude Fable 5.1 --- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 13 +++++++------ apps/mobile/src/app/debates.tsx | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 0d28c8494..7fb4acd42 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -281,6 +281,7 @@ function SparCard({ side, onPress }: { side: DebateSide; onPress: () => void }) ); } +// Long one-word labels (Hungarian "Folyamatábra") shrink to fit on iOS; the web preview cannot shrink text, so it truncates them. function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onPress: () => void }) { const { colors } = useTheme(); return ( @@ -292,8 +293,8 @@ function ToolTile({ icon, label, onPress }: { icon: IconName; label: string; onP { backgroundColor: colors.card, borderColor: colors.border }, pressed && styles.pressed, ]}> - - + + {label} @@ -385,13 +386,13 @@ const styles = StyleSheet.create({ flexBasis: '45%', flexDirection: 'row', alignItems: 'center', - gap: 12, + gap: 10, borderWidth: 1, borderRadius: radius.card, - paddingVertical: 11, - paddingHorizontal: 12, + paddingVertical: 12, + paddingHorizontal: 10, }, - tileLabel: { flex: 1, fontSize: 16 }, + tileLabel: { flex: 1, fontSize: 15.5 }, list: { padding: 0, gap: 0 }, row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 }, rowText: { flex: 1, gap: 2 }, diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 3ce82104d..ef9d06c34 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -163,7 +163,7 @@ export default function DebatesSheet() { - + From df9c9988990c4f08bcd8f2c5ecb638dac2c52182 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 07:54:40 +0000 Subject: [PATCH 06/12] Make the side toggle a pro and con preparation view Students prepare both sides of a motion and rarely know theirs in advance, so the Debate tab no longer stores a side per debate. Its In favor / Against toggle now only chooses which side's results and recent work it shows, using the website's rule: 'both' counts for either side, neutral and side-less work for neither. Sources and rounds stay whole-debate counts. The toggle starts on In favor for each debate and is never stored. The new-debate sheet loses its side field, and the Spar card shows the format and that the side is picked when the round starts. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/scripts/screenshots.ts | 7 +- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 82 +++++++++++-------- apps/mobile/src/app/delete-account.tsx | 2 +- apps/mobile/src/app/new-debate.tsx | 23 +----- apps/mobile/src/lib/debate-context.tsx | 47 +---------- apps/mobile/src/lib/debates.ts | 38 ++++++--- docs/development.md | 2 +- src/locales/en.json | 7 +- src/locales/es.json | 7 +- src/locales/hu.json | 7 +- 11 files changed, 96 insertions(+), 128 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 71ccd1a42..0d4bd83ec 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -19,7 +19,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - `src/app/`: routes. `sign-in.tsx`; `(tabs)/` with one route group per tab: Debate, Prepare, Practice, Library, You; and the form sheets `debates.tsx` (switcher), `new-debate.tsx` and `delete-account.tsx`, declared in the root `_layout.tsx`. - `src/components/`: shared UI. `app-tabs.tsx` uses native tabs; `app-tabs.web.tsx` draws the mock's tab bar for the web preview. - `src/lib/`: auth (`auth.tsx`), the Supabase client, session storage (AES key in SecureStore, ciphertext in AsyncStorage) and i18n. `debates.ts`, `settings.tsx`, `subscription.ts` and `account.ts` read and write the website's tables the way its stores do (`src/lib/stores/debate-store.ts`, `settings-store.ts`, `src/hooks/use-subscription.ts`, `src/lib/account.ts`), so the app and the website share one Current Debate and one set of settings. Change them together with the website code they mirror. -- One-off mode is session-only and never written, as on the website. The side shown on the Debate tab is kept on the device per account and debate, because `debates` has no side column and the website drops unknown keys from a debate's `settings`. +- One-off mode is session-only and never written, as on the website. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index 258bf902c..7f6d5d9b9 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -21,7 +21,7 @@ * therefore answer the app's user_settings reads with the run's language and * the System theme; nothing is written. The Debate tab shows the account's * real Current Debate, and its empty state comes from answering the - * current_debates read with no row. + * current_debates read with no row. The Debate tab is shot once per side view. */ import { chromium, type Browser, type BrowserContextOptions, type Page } from '@playwright/test'; import { createReadStream, existsSync, statSync } from 'node:fs'; @@ -125,6 +125,9 @@ async function shootDebateScreens(page: Page, prefix: string) { await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); await settle(page); await shoot(page, `${prefix}-debate`); + // Both sides' work is already loaded, so the Against view needs no new request. + await page.getByRole('tab', { name: /^(Against|Ellene|En contra)$/ }).click(); + await shoot(page, `${prefix}-debate-con`); await page.getByTestId('current-debate-pill').click(); await page.getByTestId('debates-sheet').waitFor(); @@ -261,7 +264,7 @@ async function main() { await shoot(tabPage, `${prefix}-failed`); throw error; }); - shots += 3; + shots += 4; for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) { await tabPage.goto(`${ORIGIN}${route}`); await tabPage.getByRole('tablist').first().waitFor(); diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 7fb4acd42..aa8e1dbfb 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -11,8 +11,15 @@ import type { IconName } from '@/components/icon-paths'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; -import { useDebates, type DebateSide } from '@/lib/debate-context'; -import { fetchDebateCounts, fetchRecentWork, type DebateCounts, type DebateItem, type DebateItemKind } from '@/lib/debates'; +import { useDebates } from '@/lib/debate-context'; +import { + fetchDebateCounts, + fetchRecentWork, + type DebateCounts, + type DebateItem, + type DebateItemKind, + type DebateSide, +} from '@/lib/debates'; import { formatWhen } from '@/lib/dates'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -44,7 +51,7 @@ const PRACTICE_KINDS = new Set(['attack', 'crossfire', 'sparring export default function DebateScreen() { const { t } = useTranslation(); const router = useRouter(); - const { status, current, oneOff, setOneOff, refresh, sideOf, setSide } = useDebates(); + const { status, current, oneOff, setOneOff, refresh } = useDebates(); // Debates switched on the website show up when the tab comes back into view. const focusedBefore = useRef(false); @@ -95,8 +102,6 @@ export default function DebateScreen() { ); } - const side = sideOf(current.id); - return ( @@ -104,17 +109,7 @@ export default function DebateScreen() { {current.motion} - - - options={[ - { value: 'pro', label: t('debate.side.pro') }, - { value: 'con', label: t('debate.side.con') }, - ]} - value={side} - onChange={(next) => setSide(current.id, next)} - /> - - + ); } @@ -150,20 +145,30 @@ function OneOffNotice({ onReturn }: { onReturn: () => void }) { ); } -/** Counts, the main action, the Prepare tools and recent work for one debate. */ -function DebateDetails({ debateId, side }: { debateId: string; side: DebateSide }) { +/** + * Counts, the main action, the Prepare tools and recent work for one debate. + * Students prepare both sides, so the toggle only chooses which side's work is + * shown. It starts on In favor for each debate and is never stored. + */ +function DebateDetails({ debateId }: { debateId: string }) { const { t } = useTranslation(); const router = useRouter(); + const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); - const [recent, setRecent] = useState(null); + const [recent, setRecent] = useState | null>(null); const [failed, setFailed] = useState(false); + // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { try { - const [nextCounts, nextRecent] = await Promise.all([fetchDebateCounts(debateId), fetchRecentWork(debateId, RECENT_LIMIT)]); + const [nextCounts, pro, con] = await Promise.all([ + fetchDebateCounts(debateId), + fetchRecentWork(debateId, 'pro', RECENT_LIMIT), + fetchRecentWork(debateId, 'con', RECENT_LIMIT), + ]); if (!isCurrent()) return; setCounts(nextCounts); - setRecent(nextRecent); + setRecent({ pro, con }); setFailed(false); } catch (cause) { if (!isCurrent()) return; @@ -184,16 +189,29 @@ function DebateDetails({ debateId, side }: { debateId: string; side: DebateSide ); const open = (href: Href) => router.navigate(href); + const results = counts?.results[view]; + const items = recent?.[view] ?? null; return ( <> + + + options={[ + { value: 'pro', label: t('debate.side.pro') }, + { value: 'con', label: t('debate.side.con') }, + ]} + value={view} + onChange={setView} + /> + + - + - open('/practice')} /> + open('/practice')} /> {t('mobile.tabs.prepare')} @@ -214,17 +232,17 @@ function DebateDetails({ debateId, side }: { debateId: string; side: DebateSide void load(() => true)} /> - ) : recent === null ? ( + ) : items === null ? ( - ) : recent.length === 0 ? ( + ) : items.length === 0 ? ( - {t('debate.noWork')} + {t('debate.sideEmpty')} ) : ( - {recent.map((item, index) => ( + {items.map((item, index) => ( void }) { +function SparCard({ onPress }: { onPress: () => void }) { const { t } = useTranslation(); return ( void }) {t('mobile.debate.spar')} - - {t('mobile.debate.sparLine', { - format: t('sparring.format.standard'), - side: t(side === 'pro' ? 'sparring.pro' : 'sparring.con'), - })} - + {t('sparring.format.standard')} + {t('mobile.debate.sparSide')} {/* A View, so the web preview paints it above the gradient like the other children. */} diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx index 472520d76..14e0ceb08 100644 --- a/apps/mobile/src/app/delete-account.tsx +++ b/apps/mobile/src/app/delete-account.tsx @@ -12,7 +12,7 @@ import { deleteAccount, deletionConfirmation } from '@/lib/account'; import { useAuth } from '@/lib/auth'; import { useTheme } from '@/theme/use-theme'; -/** Removes what this device kept for the account: its settings and debate sides. */ +/** Removes what this device kept for the account: its settings. */ async function forgetAccount(userId: string) { const keys = await AsyncStorage.getAllKeys(); await AsyncStorage.multiRemove(keys.filter((key) => key.includes(userId))); diff --git a/apps/mobile/src/app/new-debate.tsx b/apps/mobile/src/app/new-debate.tsx index a3d0ea410..3760d78f4 100644 --- a/apps/mobile/src/app/new-debate.tsx +++ b/apps/mobile/src/app/new-debate.tsx @@ -8,13 +8,12 @@ import { MainActionButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Field } from '@/components/field'; import { Icon } from '@/components/icon'; -import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; -import { useDebates, type DebateSide } from '@/lib/debate-context'; +import { useDebates } from '@/lib/debate-context'; import { DEBATE_FORMATS, MAX_MOTION_LENGTH, type DebateFormat } from '@/lib/debates'; import { useTheme } from '@/theme/use-theme'; -/** Start a debate: motion, side and format, like the website's Start new debate dialog. It becomes current. */ +/** Start a debate: motion and format, like the website's Start new debate dialog. It becomes current. */ export default function NewDebateSheet() { const { t } = useTranslation(); const router = useRouter(); @@ -22,7 +21,6 @@ export default function NewDebateSheet() { const { colors } = useTheme(); const { create } = useDebates(); const [motion, setMotion] = useState(''); - const [side, setSide] = useState('pro'); const [format, setFormat] = useState('custom'); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -34,7 +32,7 @@ export default function NewDebateSheet() { setError(''); try { // The website's form saves the role too; it has no role picker here yet, so it keeps the default. - await create({ motion, settings: { format, role: 'general' }, side }); + await create({ motion, settings: { format, role: 'general' } }); // Closes this sheet and the switcher under it, if it was opened from there. router.dismissAll(); } catch (cause) { @@ -74,21 +72,6 @@ export default function NewDebateSheet() { style={styles.motion} /> - - - {t('mobile.newDebate.side')} - - - options={[ - { value: 'pro', label: t('debate.side.pro') }, - { value: 'con', label: t('debate.side.con') }, - ]} - value={side} - onChange={setSide} - disabled={saving} - /> - - {t('debate.format')} diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index b88f32356..c6ec4771c 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -1,4 +1,3 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { AppState } from 'react-native'; @@ -12,9 +11,6 @@ import { type DebateSettings, } from './debates'; -/** The side the student argues in a debate. */ -export type DebateSide = 'pro' | 'con'; - type Status = 'loading' | 'ready' | 'error'; type DebateContextValue = { @@ -26,19 +22,13 @@ type DebateContextValue = { /** The debate that new work attaches to right now, or null for one-off work. */ activeDebateId: string | null; refresh: () => Promise; - create: (input: { motion: string; settings: DebateSettings; side: DebateSide }) => Promise; + create: (input: { motion: string; settings: DebateSettings }) => Promise; switchTo: (id: string) => Promise; setOneOff: (oneOff: boolean) => void; - sideOf: (debateId: string) => DebateSide; - setSide: (debateId: string, side: DebateSide) => void; }; const DebateContext = createContext(null); -// The debates table has no side, and the website drops unknown keys from a -// debate's settings, so the side is kept on this device per account and debate. -const sideKey = (userId: string, debateId: string) => `debatelab.debate-side.${userId}.${debateId}`; - export function DebateProvider({ children }: { children: ReactNode }) { const { session } = useAuth(); const userId = session?.user.id ?? null; @@ -46,7 +36,6 @@ export function DebateProvider({ children }: { children: ReactNode }) { const [debates, setDebates] = useState([]); const [currentId, setCurrentId] = useState(null); const [oneOff, setOneOff] = useState(false); - const [sides, setSides] = useState>({}); // A load that started before a create or switch must not overwrite it, and // nothing from a previous account may land after a sign-out. const writes = useRef(0); @@ -75,7 +64,6 @@ export function DebateProvider({ children }: { children: ReactNode }) { setDebates([]); setCurrentId(null); setOneOff(false); - setSides({}); setStatus('loading'); if (!userId) return; void refresh(); @@ -86,32 +74,6 @@ export function DebateProvider({ children }: { children: ReactNode }) { return () => subscription.remove(); }, [userId, refresh]); - // Load the stored side for every debate this account has. - useEffect(() => { - if (!userId || debates.length === 0) return; - let cancelled = false; - void AsyncStorage.multiGet(debates.map((debate) => sideKey(userId, debate.id))).then((entries) => { - if (cancelled) return; - const loaded: Record = {}; - entries.forEach(([, value], index) => { - if (value === 'pro' || value === 'con') loaded[debates[index].id] = value; - }); - setSides((previous) => ({ ...loaded, ...previous })); - }).catch((cause: unknown) => console.warn('Could not read debate sides:', cause)); - return () => { - cancelled = true; - }; - }, [userId, debates]); - - const setSide = useCallback((debateId: string, side: DebateSide) => { - setSides((previous) => ({ ...previous, [debateId]: side })); - if (userId) { - AsyncStorage.setItem(sideKey(userId, debateId), side).catch((cause: unknown) => - console.warn('Could not save the debate side:', cause), - ); - } - }, [userId]); - const value = useMemo(() => { const current = debates.find((debate) => debate.id === currentId) ?? null; return { @@ -121,11 +83,10 @@ export function DebateProvider({ children }: { children: ReactNode }) { oneOff, activeDebateId: oneOff ? null : current?.id ?? null, refresh, - create: async ({ motion, settings, side }) => { + create: async ({ motion, settings }) => { if (!userId) throw new Error('Sign in to manage debates'); const debate = await createDebate(userId, { motion, settings }); writes.current += 1; - setSide(debate.id, side); setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); setCurrentId(debate.id); setOneOff(false); @@ -141,10 +102,8 @@ export function DebateProvider({ children }: { children: ReactNode }) { setOneOff(false); }, setOneOff, - sideOf: (debateId) => sides[debateId] ?? 'pro', - setSide, }; - }, [status, debates, currentId, oneOff, sides, userId, refresh, setSide]); + }, [status, debates, currentId, oneOff, userId, refresh]); return {children}; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index a5d896a84..43ae959f7 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -130,29 +130,45 @@ const KIND_BY_TABLE: Record = { crossfires: 'crossfire', }; +/** The two sides a debate is prepared for. */ +export type DebateSide = 'pro' | 'con'; + +/** + * The saved sides that belong to each side's preparation. As on the website's + * Current Debate page, 'both' counts for either side and neutral or side-less + * work counts for neither. + */ +const SIDES_FOR: Record = { pro: ['pro', 'both'], con: ['con', 'both'] }; + export interface DebateCounts { sources: number; - results: number; + /** Saved results per side. */ + results: Record; rounds: number; } -async function countRows(table: string, debateId: string): Promise { - const { count, error } = await supabase +async function countRows(table: string, debateId: string, sides?: string[]): Promise { + let query = supabase .from(table) .select('id', { count: 'exact', head: true }) .eq('debate_id', debateId); + if (sides) query = query.in('side', sides); + const { count, error } = await query; if (error) throw new Error(error.message); return count ?? 0; } -/** Sources, saved results and Sparring rounds in one debate. One-off work has no debate and never counts. */ +const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); + +/** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ export async function fetchDebateCounts(debateId: string): Promise { - const [sources, rounds, ...results] = await Promise.all([ + const [sources, rounds, pro, con] = await Promise.all([ countRows('sources', debateId), countRows('sparring_sessions', debateId), - ...RESULT_TABLES.map((table) => countRows(table, debateId)), + Promise.all(RESULT_TABLES.map((table) => countRows(table, debateId, SIDES_FOR.pro))), + Promise.all(RESULT_TABLES.map((table) => countRows(table, debateId, SIDES_FOR.con))), ]); - return { sources, rounds, results: results.reduce((sum, count) => sum + count, 0) }; + return { sources, rounds, results: { pro: sum(pro), con: sum(con) } }; } /** Library sources per debate, for the switcher's list. */ @@ -168,14 +184,15 @@ export interface DebateItem { createdAt: number; } -/** The newest saved results and rounds in a debate, like the website's Recent work. */ -export async function fetchRecentWork(debateId: string, limit: number): Promise { +/** The newest saved results and rounds for one side of a debate, like the website's side lists. */ +export async function fetchRecentWork(debateId: string, side: DebateSide, limit: number): Promise { const lists = await Promise.all([ ...RESULT_TABLES.map(async (table) => { const { data, error } = await supabase .from(table) .select('id,title,created_at') .eq('debate_id', debateId) + .in('side', SIDES_FOR[side]) .order('created_at', { ascending: false }) .limit(limit); if (error) throw new Error(error.message); @@ -187,11 +204,12 @@ export async function fetchRecentWork(debateId: string, limit: number): Promise< })); }), (async () => { - // The website lists a round under its motion and orders it by its last update. + // The website lists a round under its motion and the side the student spoke, ordered by its last update. const { data, error } = await supabase .from('sparring_sessions') .select('id,motion,updated_at') .eq('debate_id', debateId) + .eq('user_side', side) .order('updated_at', { ascending: false }) .limit(limit); if (error) throw new Error(error.message); diff --git a/docs/development.md b/docs/development.md index 72fc8b40d..32c7fd3d0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -108,7 +108,7 @@ npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts ``` -Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs, the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. +Screenshots go to `.verify/mobile-shots/`: sign-in at iPhone and iPad sizes, light and dark, English and Hungarian. With `STAGING_DEMO_CODE` set, the script also signs in to staging, captures the five tabs (the Debate tab in both its In favor and Against views), the debate switcher, the new-debate form and the Debate tab's empty state, and checks that a cold start without a network keeps the user signed in, also after supabase-js's first auth event settles (about 30 seconds offline). Signed in, it answers the app's `user_settings` reads with the run's language and the System theme, because the account's saved choices would otherwise override them; nothing is written. `ONLY=iphone-light-en` limits a run to one combination. Use the seeded Pro code from `~/.config/debatelab/staging-access.env` (see [staging.md](staging.md)), load it inside the command without printing it, and serve on `PORT=18080`: the deployed Edge Functions only accept browser calls from allowed origins such as `http://127.0.0.1:18080`. ### Expo Go on Adam's iPhone diff --git a/src/locales/en.json b/src/locales/en.json index 4e303aeb7..0ddeaf840 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2418,11 +2418,11 @@ "statRounds_one": "round", "statRounds_other": "rounds", "spar": "Spar against the AI", - "sparLine": "{{format}}, you speak {{side}}", + "sparSide": "You pick your side when the round starts", "pickUp": "Pick up where you left off", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Could not load your debate.", - "sideAria": "Your side" + "viewAria": "Show preparation for" }, "common": { "done": "Done" @@ -2439,9 +2439,6 @@ "newDebate": "New debate", "loadFailed": "Could not load your debates." }, - "newDebate": { - "side": "Your side" - }, "you": { "usage": "{{used}} of {{limit}} AI uses today", "loadFailed": "Could not load your subscription.", diff --git a/src/locales/es.json b/src/locales/es.json index 90af8cf0c..5d89df956 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2418,11 +2418,11 @@ "statRounds_one": "ronda", "statRounds_other": "rondas", "spar": "Entrena contra la IA", - "sparLine": "{{format}}, tu postura: {{side}}", + "sparSide": "Eliges tu postura al empezar la ronda", "pickUp": "Continúa donde lo dejaste", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "No se pudo cargar tu debate.", - "sideAria": "Tu postura" + "viewAria": "Mostrar la preparación de" }, "common": { "done": "Listo" @@ -2439,9 +2439,6 @@ "newDebate": "Nuevo debate", "loadFailed": "No se pudieron cargar tus debates." }, - "newDebate": { - "side": "Tu postura" - }, "you": { "usage": "{{used}} de {{limit}} usos de IA hoy", "loadFailed": "No se pudo cargar tu suscripción.", diff --git a/src/locales/hu.json b/src/locales/hu.json index 1a81ec002..f895400ef 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2418,11 +2418,11 @@ "statRounds_one": "forduló", "statRounds_other": "forduló", "spar": "Gyakorolj az MI ellen", - "sparLine": "{{format}}, a te oldalad: {{side}}", + "sparSide": "Az oldaladat a forduló elején választod", "pickUp": "Folytasd, ahol abbahagytad", "itemMeta": "{{kind}}, {{when}}", "loadFailed": "Nem sikerült betölteni a vitát.", - "sideAria": "A te oldalad" + "viewAria": "Felkészülés megjelenítése" }, "common": { "done": "Kész" @@ -2439,9 +2439,6 @@ "newDebate": "Új vita", "loadFailed": "Nem sikerült betölteni a vitáidat." }, - "newDebate": { - "side": "A te oldalad" - }, "you": { "usage": "Ma {{used}} / {{limit}} MI-használat", "loadFailed": "Nem sikerült betölteni az előfizetésedet.", From 2bca0080ee1b9714efd4f1c2d624f5bdd73404ed Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:06:26 +0000 Subject: [PATCH 07/12] Keep debate writes and loads with the account and request that started them Creating or switching a debate now reads the signed-in account when the student acts and, if the account changed while the write ran, leaves the screen alone instead of showing the result to the next account. Overlapping refreshes carry a sequence number, so only the latest one lands. Every debate read, count and recent-work query, and the activity update on a switch, also filter on the user as defence in depth behind row level security. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 12 ++++-- apps/mobile/src/app/debates.tsx | 9 ++-- apps/mobile/src/lib/debate-context.tsx | 30 +++++++++----- apps/mobile/src/lib/debates.ts | 41 +++++++++++++------ 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index aa8e1dbfb..ff1759905 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -11,6 +11,7 @@ import type { IconName } from '@/components/icon-paths'; import { Screen } from '@/components/screen'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchDebateCounts, @@ -153,6 +154,8 @@ function OneOffNotice({ onReturn }: { onReturn: () => void }) { function DebateDetails({ debateId }: { debateId: string }) { const { t } = useTranslation(); const router = useRouter(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; const [view, setView] = useState('pro'); const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); @@ -160,11 +163,12 @@ function DebateDetails({ debateId }: { debateId: string }) { // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { + if (!userId) return; try { const [nextCounts, pro, con] = await Promise.all([ - fetchDebateCounts(debateId), - fetchRecentWork(debateId, 'pro', RECENT_LIMIT), - fetchRecentWork(debateId, 'con', RECENT_LIMIT), + fetchDebateCounts(userId, debateId), + fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT), + fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT), ]); if (!isCurrent()) return; setCounts(nextCounts); @@ -175,7 +179,7 @@ function DebateDetails({ debateId }: { debateId: string }) { console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [debateId]); + }, [userId, debateId]); // Reload on focus, so work saved on the website shows up. useFocusEffect( diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index ef9d06c34..fd2f11f17 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -8,6 +8,7 @@ import { PrimaryButton, TextButton } from '@/components/buttons'; import { Card } from '@/components/card'; import { Icon } from '@/components/icon'; import { Text } from '@/components/text'; +import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchSourceCounts, type Debate } from '@/lib/debates'; import { formatRelativeDate, isToday } from '@/lib/dates'; @@ -26,6 +27,8 @@ export default function DebatesSheet() { const insets = useSafeAreaInsets(); const { colors } = useTheme(); const { status, debates, current, oneOff, setOneOff, switchTo, refresh } = useDebates(); + const { session } = useAuth(); + const userId = session?.user.id ?? null; const [query, setQuery] = useState(''); const [sourceCounts, setSourceCounts] = useState>({}); const [switching, setSwitching] = useState(null); @@ -37,9 +40,9 @@ export default function DebatesSheet() { const ids = debates.map((debate) => debate.id).join(','); useEffect(() => { - if (!ids) return; + if (!userId || !ids) return; let active = true; - fetchSourceCounts(ids.split(',')) + fetchSourceCounts(userId, ids.split(',')) .then((counts) => { if (active) setSourceCounts(counts); }) @@ -47,7 +50,7 @@ export default function DebatesSheet() { return () => { active = false; }; - }, [ids]); + }, [userId, ids]); const visible = useMemo(() => { const needle = normalize(query); diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index c6ec4771c..05e18e727 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -36,23 +36,26 @@ export function DebateProvider({ children }: { children: ReactNode }) { const [debates, setDebates] = useState([]); const [currentId, setCurrentId] = useState(null); const [oneOff, setOneOff] = useState(false); - // A load that started before a create or switch must not overwrite it, and - // nothing from a previous account may land after a sign-out. + // A load that started before a create or switch must not overwrite it, only + // the latest of overlapping loads may land, and nothing from a previous + // account may land after a sign-out. const writes = useRef(0); + const refreshes = useRef(0); const owner = useRef(userId); const refresh = useCallback(async () => { const forUser = owner.current; if (!forUser) return; const writesBefore = writes.current; + const request = ++refreshes.current; try { - const next = await fetchDebates(); - if (owner.current !== forUser || writes.current !== writesBefore) return; + const next = await fetchDebates(forUser); + if (owner.current !== forUser || refreshes.current !== request || writes.current !== writesBefore) return; setDebates(next.debates); setCurrentId(next.currentId); setStatus('ready'); } catch (cause) { - if (owner.current !== forUser) return; + if (owner.current !== forUser || refreshes.current !== request) return; console.warn('Could not load debates:', cause instanceof Error ? cause.message : cause); // Keep what is on screen; only an empty first load becomes an error. setStatus((previous) => (previous === 'ready' ? previous : 'error')); @@ -83,9 +86,14 @@ export function DebateProvider({ children }: { children: ReactNode }) { oneOff, activeDebateId: oneOff ? null : current?.id ?? null, refresh, + // The account is read when the student acts. If it changed while the + // write ran, the write still happened for that account, so it is + // reported as done but not shown to the account signed in now. create: async ({ motion, settings }) => { - if (!userId) throw new Error('Sign in to manage debates'); - const debate = await createDebate(userId, { motion, settings }); + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to manage debates'); + const debate = await createDebate(forUser, { motion, settings }); + if (owner.current !== forUser) return debate; writes.current += 1; setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); setCurrentId(debate.id); @@ -94,8 +102,10 @@ export function DebateProvider({ children }: { children: ReactNode }) { return debate; }, switchTo: async (id) => { - if (!userId) throw new Error('Sign in to manage debates'); - const at = await switchDebate(userId, id); + const forUser = owner.current; + if (!forUser) throw new Error('Sign in to manage debates'); + const at = await switchDebate(forUser, id); + if (owner.current !== forUser) return; writes.current += 1; setDebates((previous) => sortDebates(previous.map((item) => (item.id === id ? { ...item, lastActivityAt: at } : item)))); setCurrentId(id); @@ -103,7 +113,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { }, setOneOff, }; - }, [status, debates, currentId, oneOff, userId, refresh]); + }, [status, debates, currentId, oneOff, refresh]); return {children}; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 43ae959f7..d3c63de72 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -71,11 +71,16 @@ async function setCurrentPointer(userId: string, debateId: string) { if (error) throw new Error(error.message); } +/** + * Every read and write below also filters on the user. Row level security is + * the real boundary; the filter keeps a wrong session from mixing accounts. + */ + /** Every debate, newest activity first, and the current one when it is still in the list. */ -export async function fetchDebates(): Promise<{ debates: Debate[]; currentId: string | null }> { +export async function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { const [debatesResult, currentResult] = await Promise.all([ - supabase.from('debates').select(DEBATE_COLUMNS).order('last_activity_at', { ascending: false }), - supabase.from('current_debates').select('debate_id').maybeSingle(), + supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), + supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), ]); if (debatesResult.error) throw new Error(debatesResult.error.message); if (currentResult.error) throw new Error(currentResult.error.message); @@ -110,7 +115,9 @@ export async function switchDebate(userId: string, debateId: string): Promise { +async function countRows(userId: string, table: string, debateId: string, sides?: string[]): Promise { let query = supabase .from(table) .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) .eq('debate_id', debateId); if (sides) query = query.in('side', sides); const { count, error } = await query; @@ -161,19 +169,19 @@ async function countRows(table: string, debateId: string, sides?: string[]): Pro const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); /** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ -export async function fetchDebateCounts(debateId: string): Promise { +export async function fetchDebateCounts(userId: string, debateId: string): Promise { const [sources, rounds, pro, con] = await Promise.all([ - countRows('sources', debateId), - countRows('sparring_sessions', debateId), - Promise.all(RESULT_TABLES.map((table) => countRows(table, debateId, SIDES_FOR.pro))), - Promise.all(RESULT_TABLES.map((table) => countRows(table, debateId, SIDES_FOR.con))), + countRows(userId, 'sources', debateId), + countRows(userId, 'sparring_sessions', debateId), + Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), + Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), ]); return { sources, rounds, results: { pro: sum(pro), con: sum(con) } }; } /** Library sources per debate, for the switcher's list. */ -export async function fetchSourceCounts(debateIds: string[]): Promise> { - const counts = await Promise.all(debateIds.map((id) => countRows('sources', id))); +export async function fetchSourceCounts(userId: string, debateIds: string[]): Promise> { + const counts = await Promise.all(debateIds.map((id) => countRows(userId, 'sources', id))); return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); } @@ -185,12 +193,18 @@ export interface DebateItem { } /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ -export async function fetchRecentWork(debateId: string, side: DebateSide, limit: number): Promise { +export async function fetchRecentWork( + userId: string, + debateId: string, + side: DebateSide, + limit: number, +): Promise { const lists = await Promise.all([ ...RESULT_TABLES.map(async (table) => { const { data, error } = await supabase .from(table) .select('id,title,created_at') + .eq('user_id', userId) .eq('debate_id', debateId) .in('side', SIDES_FOR[side]) .order('created_at', { ascending: false }) @@ -208,6 +222,7 @@ export async function fetchRecentWork(debateId: string, side: DebateSide, limit: const { data, error } = await supabase .from('sparring_sessions') .select('id,motion,updated_at') + .eq('user_id', userId) .eq('debate_id', debateId) .eq('user_side', side) .order('updated_at', { ascending: false }) From 9439d1085d9ba3a24373929e13074bf872c10081 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:09:24 +0000 Subject: [PATCH 08/12] Keep app-saved settings on the website and under later saves The website reads an empty user_settings.feature_configs as a row that was never saved and replaces its language and theme with the browser's. The app now writes the website's default feature configs with its first save while the row has none, filtered on the empty object so configs saved meanwhile stay. The default builder moves to @debatelab/core so both clients write the same value, and a test feeds such a row through the website's hydration. Each save bumps a per-key version. A load keeps the local value of any key saved after it started, and a failed save rolls back only its own key, and only while it is still that key's latest save. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/settings.tsx | 85 +++++++++++++++---- packages/core/src/ai/feature-configs.ts | 26 ++++++ src/lib/stores/settings-store.ts | 19 +---- .../settings/mobile-saved-settings.test.ts | 49 +++++++++++ 4 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/ai/feature-configs.ts create mode 100644 src/test/settings/mobile-saved-settings.test.ts diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 78834ac94..148fbb3e5 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,3 +1,4 @@ +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; @@ -11,6 +12,7 @@ import { applyColorScheme } from '@/theme/apply-color-scheme'; export type ThemePreference = 'light' | 'dark' | 'system'; type Settings = { language: AppLanguage; outputLanguage: AppLanguage; theme: ThemePreference }; +type SettingKey = keyof Settings; type SettingsContextValue = Settings & { /** @@ -26,6 +28,7 @@ type SettingsContextValue = Settings & { // The columns each setting is stored in, in the website's user_settings row. const COLUMNS = { language: 'language', outputLanguage: 'output_language', theme: 'theme' } as const; +const KEYS = Object.keys(COLUMNS) as SettingKey[]; const SIGNED_OUT: Settings = { language: deviceLanguage(), outputLanguage: 'en', theme: 'system' }; @@ -55,6 +58,33 @@ function parseSettings(value: unknown): Settings | null { const cacheKey = (userId: string) => `debatelab.settings.${userId}`; +function isEmptyObject(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0; +} + +/** + * The sign-up trigger provisions the row and clients may only update it, as in + * the website's settings store. Only the changed column is written, plus the + * website's default feature_configs while the row has none: the website reads + * an empty feature_configs as "never saved" and would replace this setting + * with the browser's. The filter keeps configs saved in the meantime. + */ +async function writeSetting(userId: string, key: SettingKey, value: string, configsMayBeEmpty: boolean) { + const changes = { [COLUMNS[key]]: value, updated_at: new Date().toISOString() }; + if (configsMayBeEmpty) { + const { data, error } = await supabase + .from('user_settings') + .update({ ...changes, feature_configs: getDefaultFeatureConfigs() }) + .eq('user_id', userId) + .containedBy('feature_configs', {}) + .select('user_id'); + if (error) throw new Error(error.message); + if (data && data.length > 0) return; + } + const { error } = await supabase.from('user_settings').update(changes).eq('user_id', userId); + if (error) throw new Error(error.message); +} + /** * Interface language, AI output language and theme from the account's * user_settings row, shared with the website. The last known values are kept @@ -66,9 +96,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const [settings, setSettings] = useState(SIGNED_OUT); const [ready, setReady] = useState(false); const owner = useRef(userId); + // The settings last applied, so a save builds on every earlier save. + const latest = useRef(settings); + // Bumped by every save of a key. A load keeps the local value of any key + // saved after it started, and a failed save undoes its key only while it is + // still the latest save of that key. + const versions = useRef>({ language: 0, outputLanguage: 0, theme: 0 }); + // Whether the row's feature_configs is filled in; null until a load or save tells. + const configsSaved = useRef(null); const apply = useCallback((next: Settings, forUser: string | null) => { if (owner.current !== forUser) return; + latest.current = next; setSettings(next); if (i18n.language !== next.language) void i18n.changeLanguage(next.language); applyColorScheme(next.theme); @@ -82,23 +121,33 @@ export function SettingsProvider({ children }: { children: ReactNode }) { useEffect(() => { if (loading) return; owner.current = userId; + configsSaved.current = null; if (!userId) { apply({ ...SIGNED_OUT, language: deviceLanguage() }, null); setReady(true); return; } let cancelled = false; + const started = { ...versions.current }; + // Loaded values, except for keys saved since the load started. + const merge = (loaded: Settings): Settings => { + const next = { ...latest.current }; + for (const key of KEYS) { + if (versions.current[key] === started[key]) Object.assign(next, { [key]: loaded[key] }); + } + return next; + }; void (async () => { try { const cached = parseSettings(JSON.parse((await AsyncStorage.getItem(cacheKey(userId))) ?? 'null')); - if (!cancelled && cached) apply(cached, userId); + if (!cancelled && cached) apply(merge(cached), userId); } catch (cause) { console.warn('Could not read settings from this device:', cause); } if (!cancelled) setReady(true); const { data, error } = await supabase .from('user_settings') - .select('language,output_language,theme') + .select('language,output_language,theme,feature_configs') .eq('user_id', userId) .maybeSingle(); if (cancelled) return; @@ -106,29 +155,31 @@ export function SettingsProvider({ children }: { children: ReactNode }) { console.warn('Could not load settings:', error.message); return; } + if (data && configsSaved.current === null) configsSaved.current = !isEmptyObject(data.feature_configs); const server = parseSettings(data); - if (server) apply(server, userId); + if (server) apply(merge(server), userId); })(); return () => { cancelled = true; }; }, [loading, userId, apply]); - const save = useCallback(async (key: K, value: Settings[K]) => { - if (!userId) return; - const previous = settings; - apply({ ...settings, [key]: value }, userId); - // The sign-up trigger provisions the row and clients may only update it, - // as in the website's settings store. Only the changed column is written. - const { error } = await supabase - .from('user_settings') - .update({ [COLUMNS[key]]: value, updated_at: new Date().toISOString() }) - .eq('user_id', userId); - if (error) { - apply(previous, userId); - throw new Error(error.message); + const save = useCallback(async (key: K, value: Settings[K]) => { + const forUser = owner.current; + if (!forUser) return; + const version = ++versions.current[key]; + const previous = latest.current[key]; + apply({ ...latest.current, [key]: value }, forUser); + try { + await writeSetting(forUser, key, value, configsSaved.current !== true); + if (owner.current === forUser) configsSaved.current = true; + } catch (cause) { + if (owner.current === forUser && versions.current[key] === version) { + apply({ ...latest.current, [key]: previous }, forUser); + } + throw cause; } - }, [userId, settings, apply]); + }, [apply]); const value = useMemo(() => ({ ...settings, diff --git a/packages/core/src/ai/feature-configs.ts b/packages/core/src/ai/feature-configs.ts new file mode 100644 index 000000000..6d7f7e789 --- /dev/null +++ b/packages/core/src/ai/feature-configs.ts @@ -0,0 +1,26 @@ +import { DEFAULT_MODEL } from '../contract/model-catalog'; +import { PROVIDER_ID, type ProviderConfig } from './providers/types'; + +export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; + +const defaultConfig: ProviderConfig = { + provider: PROVIDER_ID, + model: DEFAULT_MODEL, + temperature: 0.7, + reasoningEffort: 'low', +}; + +/** + * The model settings each tool starts with. The website saves these in + * user_settings.feature_configs, and the app writes them too when it saves the + * first setting, because the website reads an empty feature_configs as "never + * saved". + */ +export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ + generate: { ...defaultConfig, model }, + attack: { ...defaultConfig, model }, + brainstorm: { ...defaultConfig, model, temperature: 0.9 }, + factoids: { ...defaultConfig, model, temperature: 0.3 }, + ask: { ...defaultConfig, model }, + sparring: { ...defaultConfig, model }, +}); diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 6c4c56610..bfbea0427 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { applyAppLanguage } from '@/i18n'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; +import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; import { AUTO_FAST, AUTO_PRO, @@ -14,7 +15,7 @@ import { import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; -export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; +export type { FeatureTab }; type CoreFeatureTab = Exclude; type FeatureConfigs = Record & Partial>; @@ -54,22 +55,6 @@ const LEGACY_MODEL_SUCCESSORS: Record = { 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', }; -const defaultConfig: ProviderConfig = { - provider: DEFAULT_PROVIDER, - model: DEFAULT_MODEL, - temperature: 0.7, - reasoningEffort: 'low', -}; - -const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ - generate: { ...defaultConfig, model }, - attack: { ...defaultConfig, model }, - brainstorm: { ...defaultConfig, model, temperature: 0.9 }, - factoids: { ...defaultConfig, model, temperature: 0.3 }, - ask: { ...defaultConfig, model }, - sparring: { ...defaultConfig, model }, -}); - function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record diff --git a/src/test/settings/mobile-saved-settings.test.ts b/src/test/settings/mobile-saved-settings.test.ts new file mode 100644 index 000000000..eb7e16b29 --- /dev/null +++ b/src/test/settings/mobile-saved-settings.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { resetMockState, setMockTableResponse, supabaseMock } from '@/test/mocks/supabase'; + +vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/mocks/supabase')).supabaseMock })); + +const { useSettingsStore } = await import('@/lib/stores/settings-store'); + +/** Every update the store sent to Supabase. */ +const updates = () => supabaseMock.from.mock.results.flatMap((result) => result.value.update.mock.calls); + +// The iOS app saves a setting with the website's default feature_configs when +// the row has none, because the website treats an empty feature_configs as a +// row that was never saved and replaces its language and theme. +describe('settings saved by the iOS app', () => { + beforeEach(() => { + resetMockState(); + useSettingsStore.setState({ language: 'en', outputLanguage: 'en', theme: 'light' }); + }); + + it('keeps the language and theme the app saved, without rewriting the row', async () => { + setMockTableResponse('user_settings', { + single: { + language: 'hu', + output_language: 'es', + theme: 'dark', + subscription_tier: 'trial', + feature_configs: getDefaultFeatureConfigs(), + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const state = useSettingsStore.getState(); + expect([state.language, state.outputLanguage, state.theme]).toEqual(['hu', 'es', 'dark']); + expect(updates()).toEqual([]); + }); + + it('replaces them when feature_configs was left empty', async () => { + setMockTableResponse('user_settings', { + single: { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'trial', feature_configs: {} }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const state = useSettingsStore.getState(); + expect([state.language, state.outputLanguage, state.theme]).toEqual(['en', 'en', 'light']); + }); +}); From f090107bdef8e2c8218b98d401f179aed9cee486 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:09:57 +0000 Subject: [PATCH 09/12] Show only translated text when account deletion fails The delete sheet printed the server's English message under the translated heading. It now shows the heading and a translated hint that the account is still there, and logs the raw message to the console. The website maps no deletion errors, so there are no cases to translate. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/delete-account.tsx | 19 +++++++++---------- src/locales/en.json | 3 +++ src/locales/es.json | 3 +++ src/locales/hu.json | 3 +++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/app/delete-account.tsx b/apps/mobile/src/app/delete-account.tsx index 14e0ceb08..39a1d691c 100644 --- a/apps/mobile/src/app/delete-account.tsx +++ b/apps/mobile/src/app/delete-account.tsx @@ -29,19 +29,20 @@ export default function DeleteAccountSheet() { const expected = deletionConfirmation(email); const [confirmation, setConfirmation] = useState(''); const [deleting, setDeleting] = useState(false); - // null while nothing failed; the server's message, possibly empty, after a failure. - const [error, setError] = useState(null); + const [failed, setFailed] = useState(false); const remove = async () => { if (confirmation !== expected || deleting) return; const userId = session?.user.id; setDeleting(true); - setError(null); + setFailed(false); try { await deleteAccount(confirmation); } catch (cause) { + // The server's message is English and not meant for students; the sheet shows translated text only. + console.warn('Could not delete the account:', cause instanceof Error ? cause.message : cause); setDeleting(false); - setError(cause instanceof Error ? cause.message : ''); + setFailed(true); return; } if (userId) await forgetAccount(userId).catch(() => undefined); @@ -78,16 +79,14 @@ export default function DeleteAccountSheet() { returnKeyType="done" onSubmitEditing={() => void remove()} /> - {error !== null ? ( + {failed ? ( {t('settings.deleteAccountFailed')} - {error ? ( - - {error} - - ) : null} + + {t('mobile.deleteAccount.failedHint')} + ) : null} Date: Fri, 9 Oct 2026 08:23:10 +0000 Subject: [PATCH 10/12] Give Pro students the Pro model when the app saves first When the app saved a student's first setting it filled the empty feature_configs with the non-Pro default model. The website would have used the Pro default for a student with Pro access, and it never upgrades configs once they are saved, so a Pro student who first saved in the app kept the weaker model on the website. The Pro access check now lives in @debatelab/core (ai/pro-access.ts) and both the website's settings store and the app use it. The app reads the tier, grace period and subscriptions on that first save and picks DEFAULT_PRO_MODEL when the check passes. If the check can't be read, it writes only the changed column and leaves configs empty, so the next save tries again. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/settings.tsx | 43 ++++++++++++++++--- packages/core/src/ai/feature-configs.ts | 3 +- packages/core/src/ai/pro-access.ts | 32 ++++++++++++++ src/lib/stores/settings-store.ts | 29 +++++-------- .../settings/mobile-saved-settings.test.ts | 18 ++++++++ 5 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/ai/pro-access.ts diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 148fbb3e5..609c2caa9 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -1,4 +1,6 @@ import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { DEFAULT_MODEL, DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { isAppLanguage, type AppLanguage } from '@debatelab/core/languages'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; @@ -62,27 +64,56 @@ function isEmptyObject(value: unknown): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0; } +/** + * The starting model the website would pick for this student, or null when + * their Pro access can't be read right now. + */ +async function defaultModelFor(userId: string): Promise { + const { data, error } = await supabase + .from('user_settings') + .select('subscription_tier,grace_period_ends_at') + .eq('user_id', userId) + .maybeSingle(); + if (error || !data) return null; + const pro = await canAccessProModels(data, async () => { + const { data: rows, error: billingError } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId); + return billingError ? null : rows ?? []; + }); + if (pro === null) return null; + return pro ? DEFAULT_PRO_MODEL : DEFAULT_MODEL; +} + /** * The sign-up trigger provisions the row and clients may only update it, as in * the website's settings store. Only the changed column is written, plus the * website's default feature_configs while the row has none: the website reads * an empty feature_configs as "never saved" and would replace this setting - * with the browser's. The filter keeps configs saved in the meantime. + * with the browser's. The defaults use the Pro model when the student has Pro + * access, as the website's would, because the website never upgrades configs + * once they are saved. The filter keeps configs saved in the meantime. + * + * Returns whether the row's feature_configs is known to be filled in. When Pro + * access can't be read, only the column is written, so the next save retries. */ async function writeSetting(userId: string, key: SettingKey, value: string, configsMayBeEmpty: boolean) { const changes = { [COLUMNS[key]]: value, updated_at: new Date().toISOString() }; - if (configsMayBeEmpty) { + const model = configsMayBeEmpty ? await defaultModelFor(userId) : null; + if (model) { const { data, error } = await supabase .from('user_settings') - .update({ ...changes, feature_configs: getDefaultFeatureConfigs() }) + .update({ ...changes, feature_configs: getDefaultFeatureConfigs(model) }) .eq('user_id', userId) .containedBy('feature_configs', {}) .select('user_id'); if (error) throw new Error(error.message); - if (data && data.length > 0) return; + if (data && data.length > 0) return true; } const { error } = await supabase.from('user_settings').update(changes).eq('user_id', userId); if (error) throw new Error(error.message); + return !configsMayBeEmpty || model !== null; } /** @@ -171,8 +202,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const previous = latest.current[key]; apply({ ...latest.current, [key]: value }, forUser); try { - await writeSetting(forUser, key, value, configsSaved.current !== true); - if (owner.current === forUser) configsSaved.current = true; + const filled = await writeSetting(forUser, key, value, configsSaved.current !== true); + if (filled && owner.current === forUser) configsSaved.current = true; } catch (cause) { if (owner.current === forUser && versions.current[key] === version) { apply({ ...latest.current, [key]: previous }, forUser); diff --git a/packages/core/src/ai/feature-configs.ts b/packages/core/src/ai/feature-configs.ts index 6d7f7e789..d5ba62f41 100644 --- a/packages/core/src/ai/feature-configs.ts +++ b/packages/core/src/ai/feature-configs.ts @@ -14,7 +14,8 @@ const defaultConfig: ProviderConfig = { * The model settings each tool starts with. The website saves these in * user_settings.feature_configs, and the app writes them too when it saves the * first setting, because the website reads an empty feature_configs as "never - * saved". + * saved". Both pass DEFAULT_PRO_MODEL when canAccessProModels (pro-access.ts) + * says the student has Pro access. */ export const getDefaultFeatureConfigs = (model: string = DEFAULT_MODEL): Record => ({ generate: { ...defaultConfig, model }, diff --git a/packages/core/src/ai/pro-access.ts b/packages/core/src/ai/pro-access.ts new file mode 100644 index 000000000..0b954865c --- /dev/null +++ b/packages/core/src/ai/pro-access.ts @@ -0,0 +1,32 @@ +export interface BillingPeriod { + status?: string | null; + current_period_end?: string | null; +} + +/** + * Whether the student may use Pro models, from their user_settings row: a Pro + * or legacy tier in its grace period, or a Pro tier with an active or trialing + * subscription that has not ended. The website's settings store and the app + * both pick the starting model with it. loadSubscriptions runs only for a Pro + * tier outside its grace period; when it returns null, so does this. + */ +export async function canAccessProModels( + settings: { subscription_tier?: unknown; grace_period_ends_at?: unknown }, + loadSubscriptions: () => Promise, + now: Date = new Date(), +): Promise { + const tier = settings.subscription_tier; + const graceEnd = typeof settings.grace_period_ends_at === 'string' + ? new Date(settings.grace_period_ends_at) + : null; + if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > now) return true; + if (tier !== 'pro') return false; + + const subscriptions = await loadSubscriptions(); + if (!subscriptions) return null; + return subscriptions.some((subscription) => + (subscription.status === 'active' || subscription.status === 'trialing') + && !!subscription.current_period_end + && new Date(subscription.current_period_end) > now + ); +} diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index bfbea0427..2c2a6cd39 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; import { applyAppLanguage } from '@/i18n'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import { getDefaultFeatureConfigs, type FeatureTab } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels as canAccessProModelsFor } from '@debatelab/core/ai/pro-access'; import { AUTO_FAST, AUTO_PRO, @@ -128,25 +129,15 @@ function featureConfigsAreCanonical(value: unknown, expected: Record): Promise { - const tier = settings.subscription_tier; - const graceEnd = typeof settings.grace_period_ends_at === 'string' - ? new Date(settings.grace_period_ends_at) - : null; - if ((tier === 'pro' || tier === 'legacy_free') && graceEnd && graceEnd > new Date()) return true; - if (tier !== 'pro') return false; - - const userId = await getUserId(); - if (!userId) return null; - const { data, error } = await supabase - .from('subscriptions') - .select('status, current_period_end') - .eq('user_id', userId); - if (error) return null; - return Boolean(data?.some((subscription) => - (subscription.status === 'active' || subscription.status === 'trialing') - && subscription.current_period_end - && new Date(subscription.current_period_end) > new Date() - )); + return canAccessProModelsFor(settings, async () => { + const userId = await getUserId(); + if (!userId) return null; + const { data, error } = await supabase + .from('subscriptions') + .select('status, current_period_end') + .eq('user_id', userId); + return error ? null : data ?? []; + }); } async function getUserId() { diff --git a/src/test/settings/mobile-saved-settings.test.ts b/src/test/settings/mobile-saved-settings.test.ts index eb7e16b29..2b23812d1 100644 --- a/src/test/settings/mobile-saved-settings.test.ts +++ b/src/test/settings/mobile-saved-settings.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getDefaultFeatureConfigs } from '@debatelab/core/ai/feature-configs'; +import { canAccessProModels } from '@debatelab/core/ai/pro-access'; +import { DEFAULT_PRO_MODEL } from '@debatelab/core/contract/model-catalog'; import { resetMockState, setMockTableResponse, supabaseMock } from '@/test/mocks/supabase'; vi.mock('@/lib/supabase/client', async () => ({ supabase: (await import('@/test/mocks/supabase')).supabaseMock })); @@ -36,6 +38,22 @@ describe('settings saved by the iOS app', () => { expect(updates()).toEqual([]); }); + it('keeps the Pro model the app saved for a Pro student, without rewriting the row', async () => { + const subscriptions = [{ status: 'active', current_period_end: new Date(Date.now() + 86_400_000).toISOString() }]; + const row = { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'pro' }; + // The app picks its defaults with the same check the website hydrates with. + const pro = await canAccessProModels(row, async () => subscriptions); + setMockTableResponse('subscriptions', { select: subscriptions }); + setMockTableResponse('user_settings', { + single: { ...row, feature_configs: getDefaultFeatureConfigs(pro ? DEFAULT_PRO_MODEL : undefined) }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().featureConfigs.generate.model).toBe(DEFAULT_PRO_MODEL); + expect(updates()).toEqual([]); + }); + it('replaces them when feature_configs was left empty', async () => { setMockTableResponse('user_settings', { single: { language: 'hu', output_language: 'es', theme: 'dark', subscription_tier: 'trial', feature_configs: {} }, From cc3a134f1b051a428ef4f318a002da61e012bca9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 11:15:49 +0000 Subject: [PATCH 11/12] Write settings saves in order and fail when Pro access is unknown Saves now go through one queue per provider, so the last choice made is the last write even when an earlier save's Pro-access lookup is slower. A save queued before an account switch is dropped and never touches the new account's state. When feature_configs may be empty and Pro access can't be read, the save now writes nothing and fails, so the You tab rolls the setting back and shows its save error. Writing the column alone let the website discard the choice on its next hydrate. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/serial-queue.test.ts | 33 ++++++++++++++++++++++ apps/mobile/src/lib/serial-queue.ts | 14 ++++++++++ apps/mobile/src/lib/settings.tsx | 35 +++++++++++++++--------- 3 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/lib/serial-queue.test.ts create mode 100644 apps/mobile/src/lib/serial-queue.ts diff --git a/apps/mobile/src/lib/serial-queue.test.ts b/apps/mobile/src/lib/serial-queue.test.ts new file mode 100644 index 000000000..644e885c1 --- /dev/null +++ b/apps/mobile/src/lib/serial-queue.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { createSerialQueue } from './serial-queue'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('createSerialQueue', () => { + it('finishes a slow task before starting the next', async () => { + const queue = createSerialQueue(); + const writes: string[] = []; + await Promise.all([ + queue(async () => { + await delay(20); + writes.push('hu'); + }), + queue(async () => { + writes.push('es'); + }), + ]); + expect(writes).toEqual(['hu', 'es']); + }); + + it('runs the next task after one fails', async () => { + const queue = createSerialQueue(); + const failed = queue(async () => { + await delay(5); + throw new Error('offline'); + }); + const next = queue(async () => 'saved'); + await expect(failed).rejects.toThrow('offline'); + await expect(next).resolves.toBe('saved'); + }); +}); diff --git a/apps/mobile/src/lib/serial-queue.ts b/apps/mobile/src/lib/serial-queue.ts new file mode 100644 index 000000000..b24ac115a --- /dev/null +++ b/apps/mobile/src/lib/serial-queue.ts @@ -0,0 +1,14 @@ +export type SerialQueue = (task: () => Promise) => Promise; + +/** + * Runs tasks one at a time, in the order they were added: each starts after + * the one before it settles. A failed task does not stop the ones after it. + */ +export function createSerialQueue(): SerialQueue { + let tail: Promise = Promise.resolve(); + return (task) => { + const run = tail.then(task); + tail = run.catch(() => {}); + return run; + }; +} diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 609c2caa9..7972daa3c 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -7,6 +7,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { useAuth } from './auth'; import i18n, { deviceLanguage } from './i18n'; +import { createSerialQueue } from './serial-queue'; import { supabase } from './supabase'; import { applyColorScheme } from '@/theme/apply-color-scheme'; @@ -93,15 +94,18 @@ async function defaultModelFor(userId: string): Promise { * an empty feature_configs as "never saved" and would replace this setting * with the browser's. The defaults use the Pro model when the student has Pro * access, as the website's would, because the website never upgrades configs - * once they are saved. The filter keeps configs saved in the meantime. + * once they are saved. The filter keeps configs saved in the meantime; when it + * matches nothing, configs were filled elsewhere and only the column is written. * - * Returns whether the row's feature_configs is known to be filled in. When Pro - * access can't be read, only the column is written, so the next save retries. + * When configs may be empty and Pro access can't be read, nothing is written + * and the save fails: a column saved alone would be replaced by the website. + * Once this resolves, the row's feature_configs is filled in. */ async function writeSetting(userId: string, key: SettingKey, value: string, configsMayBeEmpty: boolean) { const changes = { [COLUMNS[key]]: value, updated_at: new Date().toISOString() }; - const model = configsMayBeEmpty ? await defaultModelFor(userId) : null; - if (model) { + if (configsMayBeEmpty) { + const model = await defaultModelFor(userId); + if (!model) throw new Error('Could not read Pro access to fill in the default feature configs'); const { data, error } = await supabase .from('user_settings') .update({ ...changes, feature_configs: getDefaultFeatureConfigs(model) }) @@ -109,11 +113,10 @@ async function writeSetting(userId: string, key: SettingKey, value: string, conf .containedBy('feature_configs', {}) .select('user_id'); if (error) throw new Error(error.message); - if (data && data.length > 0) return true; + if (data && data.length > 0) return; } const { error } = await supabase.from('user_settings').update(changes).eq('user_id', userId); if (error) throw new Error(error.message); - return !configsMayBeEmpty || model !== null; } /** @@ -135,6 +138,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const versions = useRef>({ language: 0, outputLanguage: 0, theme: 0 }); // Whether the row's feature_configs is filled in; null until a load or save tells. const configsSaved = useRef(null); + // Saves write one at a time in the order they were made, so the last choice + // is the last write even when an earlier save's Pro-access check is slower. + const [writes] = useState(createSerialQueue); const apply = useCallback((next: Settings, forUser: string | null) => { if (owner.current !== forUser) return; @@ -202,15 +208,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const previous = latest.current[key]; apply({ ...latest.current, [key]: value }, forUser); try { - const filled = await writeSetting(forUser, key, value, configsSaved.current !== true); - if (filled && owner.current === forUser) configsSaved.current = true; + await writes(async () => { + // A save made before an account switch is dropped, not written for the new account. + if (owner.current !== forUser) return; + await writeSetting(forUser, key, value, configsSaved.current !== true); + if (owner.current === forUser) configsSaved.current = true; + }); } catch (cause) { - if (owner.current === forUser && versions.current[key] === version) { - apply({ ...latest.current, [key]: previous }, forUser); - } + if (owner.current !== forUser) return; + if (versions.current[key] === version) apply({ ...latest.current, [key]: previous }, forUser); throw cause; } - }, [apply]); + }, [apply, writes]); const value = useMemo(() => ({ ...settings, From 89bb34c627308fd8fcde460c49b7c6326af419fd Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 11:30:56 +0000 Subject: [PATCH 12/12] Roll failed settings saves back to the last confirmed value A failed save rolled back to the value on screen when it started, which could be another unconfirmed choice. The app now keeps, per setting, the value last loaded or saved successfully and rolls back to that. Saves, loads and debate writes were guarded by user ID, so work started before signing out and back in as the same account still landed. They now check an owner generation that is bumped on every owner change. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/debate-context.tsx | 18 ++++++---- apps/mobile/src/lib/settings.tsx | 48 +++++++++++++++++--------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/lib/debate-context.tsx b/apps/mobile/src/lib/debate-context.tsx index 05e18e727..bda43d62f 100644 --- a/apps/mobile/src/lib/debate-context.tsx +++ b/apps/mobile/src/lib/debate-context.tsx @@ -38,10 +38,13 @@ export function DebateProvider({ children }: { children: ReactNode }) { const [oneOff, setOneOff] = useState(false); // A load that started before a create or switch must not overwrite it, only // the latest of overlapping loads may land, and nothing from a previous - // account may land after a sign-out. + // owner may land after a sign-out or account switch. The generation is + // bumped on every owner change, so work started before signing out and back + // in as the same account does not land either. const writes = useRef(0); const refreshes = useRef(0); const owner = useRef(userId); + const generation = useRef(0); const refresh = useCallback(async () => { const forUser = owner.current; @@ -64,6 +67,7 @@ export function DebateProvider({ children }: { children: ReactNode }) { useEffect(() => { owner.current = userId; + generation.current += 1; setDebates([]); setCurrentId(null); setOneOff(false); @@ -86,14 +90,15 @@ export function DebateProvider({ children }: { children: ReactNode }) { oneOff, activeDebateId: oneOff ? null : current?.id ?? null, refresh, - // The account is read when the student acts. If it changed while the - // write ran, the write still happened for that account, so it is - // reported as done but not shown to the account signed in now. + // The account is read when the student acts. If the owner changed while + // the write ran, the write still happened for that account, so it is + // reported as done but not shown to whoever is signed in now. create: async ({ motion, settings }) => { const forUser = owner.current; if (!forUser) throw new Error('Sign in to manage debates'); + const gen = generation.current; const debate = await createDebate(forUser, { motion, settings }); - if (owner.current !== forUser) return debate; + if (generation.current !== gen) return debate; writes.current += 1; setDebates((previous) => sortDebates([debate, ...previous.filter((item) => item.id !== debate.id)])); setCurrentId(debate.id); @@ -104,8 +109,9 @@ export function DebateProvider({ children }: { children: ReactNode }) { switchTo: async (id) => { const forUser = owner.current; if (!forUser) throw new Error('Sign in to manage debates'); + const gen = generation.current; const at = await switchDebate(forUser, id); - if (owner.current !== forUser) return; + if (generation.current !== gen) return; writes.current += 1; setDebates((previous) => sortDebates(previous.map((item) => (item.id === id ? { ...item, lastActivityAt: at } : item)))); setCurrentId(id); diff --git a/apps/mobile/src/lib/settings.tsx b/apps/mobile/src/lib/settings.tsx index 7972daa3c..eb91586b3 100644 --- a/apps/mobile/src/lib/settings.tsx +++ b/apps/mobile/src/lib/settings.tsx @@ -130,11 +130,18 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const [settings, setSettings] = useState(SIGNED_OUT); const [ready, setReady] = useState(false); const owner = useRef(userId); + // Bumped on every owner change, including signing out and back in as the + // same account. Async work from an earlier generation never lands, even when + // the same account is signed in again. + const generation = useRef(0); // The settings last applied, so a save builds on every earlier save. const latest = useRef(settings); + // Per key, the value the account's row last held as far as the app knows: + // from a load, or from a save that succeeded. A failed save rolls back to it. + const confirmed = useRef({ ...SIGNED_OUT }); // Bumped by every save of a key. A load keeps the local value of any key - // saved after it started, and a failed save undoes its key only while it is - // still the latest save of that key. + // saved after it started, and a failed save rolls its key back only while it + // is still the latest save of that key. const versions = useRef>({ language: 0, outputLanguage: 0, theme: 0 }); // Whether the row's feature_configs is filled in; null until a load or save tells. const configsSaved = useRef(null); @@ -142,8 +149,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) { // is the last write even when an earlier save's Pro-access check is slower. const [writes] = useState(createSerialQueue); - const apply = useCallback((next: Settings, forUser: string | null) => { - if (owner.current !== forUser) return; + const apply = useCallback((next: Settings, forUser: string | null, gen: number) => { + if (generation.current !== gen) return; latest.current = next; setSettings(next); if (i18n.language !== next.language) void i18n.changeLanguage(next.language); @@ -158,26 +165,32 @@ export function SettingsProvider({ children }: { children: ReactNode }) { useEffect(() => { if (loading) return; owner.current = userId; + const gen = ++generation.current; configsSaved.current = null; if (!userId) { - apply({ ...SIGNED_OUT, language: deviceLanguage() }, null); + apply({ ...SIGNED_OUT, language: deviceLanguage() }, null, gen); + confirmed.current = { ...latest.current }; setReady(true); return; } + confirmed.current = { ...latest.current }; let cancelled = false; const started = { ...versions.current }; - // Loaded values, except for keys saved since the load started. + // Loaded values, except for keys saved since the load started. The values + // taken also become the confirmed ones. const merge = (loaded: Settings): Settings => { const next = { ...latest.current }; for (const key of KEYS) { - if (versions.current[key] === started[key]) Object.assign(next, { [key]: loaded[key] }); + if (versions.current[key] !== started[key]) continue; + Object.assign(next, { [key]: loaded[key] }); + Object.assign(confirmed.current, { [key]: loaded[key] }); } return next; }; void (async () => { try { const cached = parseSettings(JSON.parse((await AsyncStorage.getItem(cacheKey(userId))) ?? 'null')); - if (!cancelled && cached) apply(merge(cached), userId); + if (!cancelled && cached) apply(merge(cached), userId, gen); } catch (cause) { console.warn('Could not read settings from this device:', cause); } @@ -194,7 +207,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } if (data && configsSaved.current === null) configsSaved.current = !isEmptyObject(data.feature_configs); const server = parseSettings(data); - if (server) apply(merge(server), userId); + if (server) apply(merge(server), userId, gen); })(); return () => { cancelled = true; @@ -204,19 +217,22 @@ export function SettingsProvider({ children }: { children: ReactNode }) { const save = useCallback(async (key: K, value: Settings[K]) => { const forUser = owner.current; if (!forUser) return; + const gen = generation.current; const version = ++versions.current[key]; - const previous = latest.current[key]; - apply({ ...latest.current, [key]: value }, forUser); + apply({ ...latest.current, [key]: value }, forUser, gen); try { await writes(async () => { - // A save made before an account switch is dropped, not written for the new account. - if (owner.current !== forUser) return; + // A save made before an owner change is dropped, not written after it, + // even when the same account has signed in again since. + if (generation.current !== gen) return; await writeSetting(forUser, key, value, configsSaved.current !== true); - if (owner.current === forUser) configsSaved.current = true; + if (generation.current !== gen) return; + configsSaved.current = true; + Object.assign(confirmed.current, { [key]: value }); }); } catch (cause) { - if (owner.current !== forUser) return; - if (versions.current[key] === version) apply({ ...latest.current, [key]: previous }, forUser); + if (generation.current !== gen) return; + if (versions.current[key] === version) apply({ ...latest.current, [key]: confirmed.current[key] }, forUser, gen); throw cause; } }, [apply, writes]);