diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index e9a41dc..1eebfb3 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,18 +1,3 @@ - - # Cirquiz Constitution ## Core Principles @@ -41,36 +26,16 @@ Deferred TODOs: None - New features MUST NOT introduce obvious regressions to existing flows. - N+1 data-fetching patterns are not allowed. -### V. Live Verification via Maestro - -All P1 user journeys MUST be verified against the running app using the Maestro MCP before -a feature is considered done. Required steps: - -- Launch the app with `mcp__maestro__launch_app` on a simulator or real device. -- Confirm expected UI state via `mcp__maestro__inspect_view_hierarchy` and/or - `mcp__maestro__take_screenshot`. -- Run scenario flows with `mcp__maestro__run_flow` or `mcp__maestro__run_flow_files` - where a repeatable flow exists. - -Unit tests and static analysis alone do NOT satisfy verification for any UI-facing feature. -At least one Maestro-based check MUST be performed per P1 user story, and its result -(screenshot or passing flow output) MUST be documented in the feature's `specs/` directory -before merge. - -**Rationale**: Cirquiz is a mobile app. Static tests can miss rendering issues, navigation -bugs, and platform-specific behavior. Maestro MCP provides direct inspection of the running -app, making acceptance verification concrete and reproducible. - ## Governance This constitution guides technical and implementation decisions for the Cirquiz monorepo. - **Constitution Check**: Each feature plan (`plan.md`) MUST include a brief note on how - all five principles apply or are intentionally excepted. + all four principles apply or are intentionally excepted. - **Exceptions**: Deviations from a principle MUST be noted in `plan.md` with a one-line rationale. - **Amendments**: Use semantic versioning — MAJOR for removals/redefinitions, MINOR for additions, PATCH for wording. Update `LAST_AMENDED_DATE` on any change. - This is a personal project; governance is lightweight by design. Common sense prevails. -**Version**: 1.1.0 | **Ratified**: 2026-03-07 | **Last Amended**: 2026-03-07 +**Version**: 2.0.0 | **Ratified**: 2026-03-07 | **Last Amended**: 2026-03-11 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index dd16c0e..83011fd 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -31,14 +31,11 @@ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -[Gates determined based on constitution file. Must address all five principles:] +[Gates determined based on constitution file. Must address all four principles:] [I. Code Quality — readability, no duplication, lint passes] [II. Testing Standards — happy-path tests for P1 journeys, written alongside code] [III. UX Consistency — navigation/error/loading patterns match existing screens] [IV. Performance — <2s interactions, no regressions, no N+1 fetching] -[V. Live Verification via Maestro — each P1 story verified by launching the app and] -[ inspecting with Maestro MCP (inspect_view_hierarchy / take_screenshot / run_flow).] -[ Screenshot or passing flow output MUST be saved to specs/ before merge.] ## Project Structure diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index 6a49ed0..cbd418d 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -26,10 +26,7 @@ **Why this priority**: [Explain the value and why it has this priority level] -**Independent Test**: [Describe how this can be tested independently. For mobile UI stories, -include the Maestro MCP steps: launch app → inspect view hierarchy / take screenshot → -run flow. E.g., "Launch app, navigate to [screen], verify [element] visible via -inspect_view_hierarchy; run maestro flow flows/[story].yaml"] +**Independent Test**: [Describe how this can be tested independently.] **Acceptance Scenarios**: @@ -44,8 +41,7 @@ inspect_view_hierarchy; run maestro flow flows/[story].yaml"] **Why this priority**: [Explain the value and why it has this priority level] -**Independent Test**: [Describe how this can be tested independently. For mobile UI stories, -include Maestro MCP steps: launch app → inspect / screenshot → run flow.] +**Independent Test**: [Describe how this can be tested independently.] **Acceptance Scenarios**: diff --git a/.specify/templates/tasks-template.md b/.specify/templates/tasks-template.md index e30c90f..8c87abe 100644 --- a/.specify/templates/tasks-template.md +++ b/.specify/templates/tasks-template.md @@ -95,10 +95,7 @@ Examples of foundational tasks (adjust based on your project): - [ ] T016 [US1] Add validation and error handling - [ ] T017 [US1] Add logging for user story 1 operations -**Checkpoint**: User Story 1 complete — verify live with Maestro MCP: - 1. `mcp__maestro__launch_app` → navigate to the relevant screen - 2. `mcp__maestro__inspect_view_hierarchy` or `mcp__maestro__take_screenshot` - 3. Save screenshot/flow result to `specs/[###-feature-name]/verification/us1/` +**Checkpoint**: User Story 1 complete — verify independently per spec.md Independent Test. --- @@ -120,8 +117,7 @@ Examples of foundational tasks (adjust based on your project): - [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py - [ ] T023 [US2] Integrate with User Story 1 components (if needed) -**Checkpoint**: User Story 2 complete — verify live with Maestro MCP and save result to -`specs/[###-feature-name]/verification/us2/` +**Checkpoint**: User Story 2 complete — verify independently per spec.md Independent Test. --- @@ -142,8 +138,7 @@ Examples of foundational tasks (adjust based on your project): - [ ] T027 [US3] Implement [Service] in src/services/[service].py - [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py -**Checkpoint**: User Story 3 complete — verify live with Maestro MCP and save result to -`specs/[###-feature-name]/verification/us3/` +**Checkpoint**: User Story 3 complete — verify independently per spec.md Independent Test. --- @@ -161,8 +156,7 @@ Examples of foundational tasks (adjust based on your project): - [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/ - [ ] TXXX Security hardening - [ ] TXXX Run quickstart.md validation -- [ ] TXXX [P] Maestro live verification — run all P1 story flows, save screenshots/results - to `specs/[###-feature-name]/verification/` before merge +- [ ] TXXX Final validation — verify all P1 story acceptance scenarios pass --- diff --git a/CLAUDE.md b/CLAUDE.md index fb4524a..32c71f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,8 @@ specs/ # Feature specs and plans ``` ## Active Technologies +- TypeScript ~5.9 + React Native 0.83.2, Expo SDK ~55, Expo Router ~55, Zustand ^5, @react-native-async-storage/async-storage, react-native-reanimated, i18next + react-i18nex (002-provider-settings) +- AsyncStorage — `@cirquiz/settings` (new) and `@cirquiz/active_game` (existing) (002-provider-settings) - **Runtime**: React Native 0.83.2, React 19.2.0 - **Framework**: Expo SDK ~55, Expo Router ~55 @@ -62,8 +64,9 @@ yarn workspace cirquiz typecheck - Zustand for global state (stores in `apps/cirquiz/src/state/`) ## Recent Changes +- 002-provider-settings: Added TypeScript ~5.9 + React Native 0.83.2, Expo SDK ~55, Expo Router ~55, Zustand ^5, @react-native-async-storage/async-storage, react-native-reanimated, i18next + react-i18nex - 001-pass-and-play-trivia: Pass-and-play local multiplayer trivia game with player setup, question flow, handoff screens, reveal, and standings - \ No newline at end of file + diff --git a/apps/.TODO.txt b/apps/.TODO.txt index 17b901c..fe2e000 100644 --- a/apps/.TODO.txt +++ b/apps/.TODO.txt @@ -1,9 +1,8 @@ This file is purely for notes and tracking things I want to implement going forward. - replace when going to the home screen -- Cache and prefetch the categories. Categories shouldn't be changing while the app is running. We can just hard code these. -- Add support for trivia API and a settings page to be able to change them. - - Along with this we should then support having multiple category selection because this api support that. also multiple difficulty +- Instead of refresshing the session for otdb when we run out of questions we should show some sort of UI to reset the session or allow changing the category selection +- On taking a long time to load for continue, if someone quits the game it should cancel the pending network request and navigaiton OFFLINE SUPPORT - Store the list of categories from otdb and return that diff --git a/apps/cirquiz/app/index.tsx b/apps/cirquiz/app/index.tsx index 0d6fddf..540bcd9 100644 --- a/apps/cirquiz/app/index.tsx +++ b/apps/cirquiz/app/index.tsx @@ -13,6 +13,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Button } from '../src/components/Button'; import { GradientScreen } from '../src/components/GradientScreen'; +import { IconButton } from '../src/components/IconButton'; import { ShineButton } from '../src/components/ShineButton'; import { useGameStore } from '../src/state/gameStore'; import { colors, fontSize, spacing } from '../src/theme'; @@ -156,6 +157,13 @@ export default function HomeScreen() { return ( + + router.push('/settings')} + color={colors.text} + /> + @@ -206,6 +214,10 @@ const styles = StyleSheet.create({ flex: 1, flexDirection: 'column', }, + topBar: { + alignItems: 'flex-end', + paddingHorizontal: spacing.sm, + }, avatarRow: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/apps/cirquiz/app/settings.tsx b/apps/cirquiz/app/settings.tsx new file mode 100644 index 0000000..c24153a --- /dev/null +++ b/apps/cirquiz/app/settings.tsx @@ -0,0 +1,72 @@ +import { router } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { GradientScreen } from '../src/components/GradientScreen'; +import { IconButton } from '../src/components/IconButton'; +import { SelectableRow } from '../src/components/SelectableRow'; +import { useSettingsStore } from '../src/state/settingsStore'; +import { colors, fontSize, fontWeight, spacing } from '../src/theme'; + +export default function SettingsScreen() { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const questionSource = useSettingsStore((s) => s.questionSource); + const setQuestionSource = useSettingsStore((s) => s.setQuestionSource); + + return ( + + + + router.back()} color={colors.text} /> + {t('settings.title')} + + + + + {t('settings.questionSource')} + setQuestionSource('otdb')} + /> + setQuestionSource('the-trivia-api')} + /> + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + }, + headerTitle: { + flex: 1, + textAlign: 'center', + fontSize: fontSize['2xl'], + fontWeight: fontWeight.bold, + color: colors.text, + }, + headerSpacer: { width: 44 }, + content: { + paddingHorizontal: spacing.xl, + paddingTop: spacing.lg, + }, + sectionLabel: { + fontSize: fontSize.md, + fontWeight: fontWeight.semibold, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, +}); diff --git a/apps/cirquiz/app/setup.tsx b/apps/cirquiz/app/setup.tsx index 4a31fee..c07d96a 100644 --- a/apps/cirquiz/app/setup.tsx +++ b/apps/cirquiz/app/setup.tsx @@ -22,7 +22,9 @@ import { Button } from '../src/components/Button'; import { AVATAR_LIST, type AvatarKey } from '../src/avatars'; import { type Difficulty } from '../src/providers'; import { useCategoryLoader } from '../src/hooks/useCategoryLoader'; +import { useToast } from '../src/hooks/useToast'; import { useGameStore } from '../src/state/gameStore'; +import { useSettingsStore } from '../src/state/settingsStore'; import { colors, fontSize, fontWeight, radius, spacing } from '../src/theme'; import { IconButton } from '../src/components/IconButton'; import LightningSVG from '../assets/lightning.svg'; @@ -77,6 +79,32 @@ export default function SetupScreen() { const [pickerIndex, setPickerIndex] = useState(null); const [stickyHeight, setStickyHeight] = useState(0); + const questionSource = useSettingsStore((s) => s.questionSource); + const isFirstRender = useRef(true); + const { showToast, ToastNode } = useToast(); + + // Refs let us read latest values in the effect without adding them as deps + const categoryRef = useRef(category); + categoryRef.current = category; + const quickPlayRef = useRef(quickPlay); + quickPlayRef.current = quickPlay; + const loadCategoriesRef = useRef(loadCategories); + loadCategoriesRef.current = loadCategories; + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + if (categoryRef.current !== undefined) { + setCategory(undefined); + showToast(t('settings.categoryResetNotice')); + } + if (!quickPlayRef.current) { + loadCategoriesRef.current(); + } + }, [questionSource, t, showToast]); + const usedAvatars = players.map((p) => p.avatar); const addPlayer = () => { @@ -175,7 +203,11 @@ export default function SetupScreen() { router.back()} color={colors.text} /> {t('setup.title', 'NEW GAME')} - + router.push('/settings')} + color={colors.text} + /> + {ToastNode} ); } diff --git a/apps/cirquiz/src/components/GradientScreen.tsx b/apps/cirquiz/src/components/GradientScreen.tsx index 73b3055..f8aa6ae 100644 --- a/apps/cirquiz/src/components/GradientScreen.tsx +++ b/apps/cirquiz/src/components/GradientScreen.tsx @@ -10,7 +10,7 @@ interface Props { showBlobs?: boolean; mode?: keyof typeof GRADIENT_COLORS; } -const LIGHTEN_AMMOUNT = 0.06; +const LIGHTEN_AMMOUNT = 0.045; const GRADIENT_COLORS = { standard: [colors.primaryFaint, colors.white, colors.difficultyFaint] as const, lighter: [ diff --git a/apps/cirquiz/src/components/Toast.tsx b/apps/cirquiz/src/components/Toast.tsx new file mode 100644 index 0000000..4206258 --- /dev/null +++ b/apps/cirquiz/src/components/Toast.tsx @@ -0,0 +1,42 @@ +import Animated from 'react-native-reanimated'; +import { StyleSheet, Text, ViewStyle } from 'react-native'; +import { colors, fontSize, radius, spacing } from '../theme'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export function Toast({ + toastMessage, + toastStyle, +}: { + toastMessage: string; + toastStyle: ViewStyle; +}) { + const insets = useSafeAreaInsets(); + + return ( + + {toastMessage} + + ); +} + +const styles = StyleSheet.create({ + toast: { + position: 'absolute', + bottom: 0, + left: spacing.xl, + right: spacing.xl, + backgroundColor: 'rgba(0,0,0,0.85)', + borderRadius: radius.md, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + alignItems: 'center', + }, + toastText: { + color: colors.white, + fontSize: fontSize.base, + textAlign: 'center', + }, +}); diff --git a/apps/cirquiz/src/hooks/useCategoryLoader.ts b/apps/cirquiz/src/hooks/useCategoryLoader.ts index 5d1f272..84a5a38 100644 --- a/apps/cirquiz/src/hooks/useCategoryLoader.ts +++ b/apps/cirquiz/src/hooks/useCategoryLoader.ts @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { OpenTriviaDbProvider } from '../providers'; +import { getProvider } from '../providers/providerFactory'; +import { useSettingsStore } from '../state/settingsStore'; export function useCategoryLoader() { const [categories, setCategories] = useState<{ id: string; name: string }[]>([]); @@ -8,7 +9,8 @@ export function useCategoryLoader() { const load = async () => { setLoading(true); try { - setCategories(await new OpenTriviaDbProvider().fetchCategories()); + const source = useSettingsStore.getState().questionSource; + setCategories(await getProvider(source).fetchCategories()); } catch (e) { console.warn('Failed to load categories:', e); } finally { diff --git a/apps/cirquiz/src/hooks/useToast.tsx b/apps/cirquiz/src/hooks/useToast.tsx new file mode 100644 index 0000000..e02d85b --- /dev/null +++ b/apps/cirquiz/src/hooks/useToast.tsx @@ -0,0 +1,37 @@ +import { useCallback, useState } from 'react'; +import { Platform, ToastAndroid } from 'react-native'; +import { + useAnimatedStyle, + useSharedValue, + withDelay, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { Toast } from '../components/Toast'; + +const TOAST_DURATION = 3000; // Duration for which the toast is visible (in milliseconds) +export function useToast() { + const opacity = useSharedValue(0); + const [message, setMessage] = useState(''); + + const showToast = useCallback( + (msg: string) => { + if (Platform.OS === 'android') { + ToastAndroid.show(msg, ToastAndroid.SHORT); + } else { + setMessage(msg); + opacity.value = withSequence( + withTiming(1, { duration: 200 }), + withDelay(TOAST_DURATION, withTiming(0, { duration: 300 })) + ); + } + }, + [opacity] + ); + + const toastStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + + const ToastNode = ; + + return { showToast, toastMessage: message, toastStyle, ToastNode }; +} diff --git a/apps/cirquiz/src/i18n/en.json b/apps/cirquiz/src/i18n/en.json index 769eb5f..19468ea 100644 --- a/apps/cirquiz/src/i18n/en.json +++ b/apps/cirquiz/src/i18n/en.json @@ -30,7 +30,7 @@ "playerName": "Player", "avatarName": { "chili": "Rosso", - "whale": "Blubber", + "whale": "Fathom", "alien": "Zorg", "jackolantern": "Grimsley", "gremlin": "Vex", @@ -45,6 +45,13 @@ "nameEmpty": "Name cannot be empty", "nameUnique": "Name must be unique" }, + "settings": { + "title": "Settings", + "questionSource": "Question Source", + "otdb": "Open Trivia Database", + "theTriviaApi": "The Trivia API", + "categoryResetNotice": "Category selection was reset." + }, "game": { "handoff": { "title": "Pass to {{name}}", diff --git a/apps/cirquiz/src/providers/index.ts b/apps/cirquiz/src/providers/index.ts index 207c391..ead62f8 100644 --- a/apps/cirquiz/src/providers/index.ts +++ b/apps/cirquiz/src/providers/index.ts @@ -1,3 +1,5 @@ export type { TriviaQuestionProvider } from './interface'; export * from './types'; export { OpenTriviaDbProvider } from './opentdb/OpenTriviaDbProvider'; +export { TheTriviaApiProvider } from './thetriviaapi/TheTriviaApiProvider'; +export { getProvider } from './providerFactory'; diff --git a/apps/cirquiz/src/providers/providerFactory.ts b/apps/cirquiz/src/providers/providerFactory.ts new file mode 100644 index 0000000..0288bb6 --- /dev/null +++ b/apps/cirquiz/src/providers/providerFactory.ts @@ -0,0 +1,34 @@ +import type { QuestionSource } from '../state/settingsStore'; +import { TriviaQuestionProvider } from './interface'; +import { OpenTriviaDbProvider } from './opentdb/OpenTriviaDbProvider'; +import { TheTriviaApiProvider } from './thetriviaapi/TheTriviaApiProvider'; + +const instances = new Map(); +const overrides = new Map(); + +export function getProvider(source: QuestionSource): TriviaQuestionProvider { + const override = overrides.get(source); + if (override) return override; + + const existing = instances.get(source); + if (existing) return existing; + + let provider: TriviaQuestionProvider; + switch (source) { + case 'otdb': + provider = new OpenTriviaDbProvider(); + break; + case 'the-trivia-api': + provider = new TheTriviaApiProvider(); + break; + } + instances.set(source, provider); + return provider; +} + +export function setProviderForTesting( + source: QuestionSource, + provider: TriviaQuestionProvider +): void { + overrides.set(source, provider); +} diff --git a/apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts b/apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts new file mode 100644 index 0000000..5dfdf5b --- /dev/null +++ b/apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts @@ -0,0 +1,90 @@ +import { shuffle } from '../../utils/shuffle'; +import { TriviaQuestionProvider } from '../interface'; +import { + Category, + Difficulty, + Question, + QuestionFetchParams, + TriviaProviderError, + TriviaProviderErrorCode, +} from '../types'; +import { TriviaApiQuestion, TriviaApiResponse } from './triviaApiTypes'; + +const BASE_URL = 'https://the-trivia-api.com/v2'; + +const CATEGORY_NAME_MAP: Record = { + arts_and_literature: 'Arts & Literature', + film_and_tv: 'Film & TV', + food_and_drink: 'Food & Drink', + general_knowledge: 'General Knowledge', + geography: 'Geography', + history: 'History', + music: 'Music', + science: 'Science', + society_and_culture: 'Society & Culture', + sport_and_leisure: 'Sport & Leisure', +}; + +export class TheTriviaApiProvider implements TriviaQuestionProvider { + async fetchQuestions(params: QuestionFetchParams): Promise { + const url = new URL(`${BASE_URL}/questions`); + url.searchParams.set('limit', String(params.count)); + if (params.category) url.searchParams.set('categories', params.category); + if (params.difficulty) url.searchParams.set('difficulties', params.difficulty); + + let data: TriviaApiResponse; + try { + const res = await fetch(url.toString()); + if (!res.ok) { + throw new TriviaProviderError('Network error', TriviaProviderErrorCode.NetworkError); + } + data = await res.json(); + } catch (e) { + if (e instanceof TriviaProviderError) throw e; + throw new TriviaProviderError('Network error', TriviaProviderErrorCode.NetworkError); + } + + if (!data || data.length === 0) { + throw new TriviaProviderError('No results available', TriviaProviderErrorCode.NoResults); + } + + return data.map( + (q: TriviaApiQuestion): Question => ({ + id: q.id, + type: 'multiple-choice', + text: q.question.text, + correctAnswer: q.correctAnswer, + options: shuffle([q.correctAnswer, ...q.incorrectAnswers]), + category: CATEGORY_NAME_MAP[q.category] || q.category, + difficulty: q.difficulty as Difficulty, + }) + ); + } + + fetchCategories(): Promise { + return Promise.resolve([ + { id: 'arts_and_literature', name: 'Arts & Literature' }, + { id: 'film_and_tv', name: 'Film & TV' }, + { id: 'food_and_drink', name: 'Food & Drink' }, + { id: 'general_knowledge', name: 'General Knowledge' }, + { id: 'geography', name: 'Geography' }, + { id: 'history', name: 'History' }, + { id: 'music', name: 'Music' }, + { id: 'science', name: 'Science' }, + { id: 'society_and_culture', name: 'Society & Culture' }, + { id: 'sport_and_leisure', name: 'Sport & Leisure' }, + ]); + } + + supportsCategories(): boolean { + return true; + } + + supportsDifficulty(): boolean { + return true; + } + + resetSession(): void { + // no-op — stateless API on the free tier + } +} diff --git a/apps/cirquiz/src/providers/thetriviaapi/__tests__/TheTriviaApiProvider.test.ts b/apps/cirquiz/src/providers/thetriviaapi/__tests__/TheTriviaApiProvider.test.ts new file mode 100644 index 0000000..520b6a3 --- /dev/null +++ b/apps/cirquiz/src/providers/thetriviaapi/__tests__/TheTriviaApiProvider.test.ts @@ -0,0 +1,149 @@ +import { TheTriviaApiProvider } from '../TheTriviaApiProvider'; +import { TriviaProviderErrorCode } from '../../types'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const makeTriviaApiQuestion = (overrides = {}) => ({ + id: '622a1c3d7fce235dd0f05b61', + category: 'general_knowledge', + difficulty: 'easy' as const, + correctAnswer: 'Paris', + incorrectAnswers: ['London', 'Berlin', 'Rome'], + question: { text: 'What is the capital of France?' }, + type: 'text_choice', + tags: [], + regions: [], + isNiche: false, + ...overrides, +}); + +describe('TheTriviaApiProvider', () => { + let provider: TheTriviaApiProvider; + + beforeEach(() => { + provider = new TheTriviaApiProvider(); + mockFetch.mockClear(); + }); + + // ─── fetchQuestions ─────────────────────────────────────────────────────── + + describe('fetchQuestions', () => { + it('fetches from the correct URL with limit param', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [makeTriviaApiQuestion()], + }); + await provider.fetchQuestions({ count: 5 }); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('limit=5')); + }); + + it('includes categories param when category is provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [makeTriviaApiQuestion()], + }); + await provider.fetchQuestions({ count: 5, category: 'science' }); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('categories=science')); + }); + + it('includes difficulties param when difficulty is provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [makeTriviaApiQuestion()], + }); + await provider.fetchQuestions({ count: 5, difficulty: 'hard' }); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('difficulties=hard')); + }); + + it('maps response fields to Question shape correctly', async () => { + const apiQ = makeTriviaApiQuestion(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [apiQ], + }); + const questions = await provider.fetchQuestions({ count: 1 }); + expect(questions).toHaveLength(1); + const q = questions[0]; + expect(q.id).toBe(apiQ.id); + expect(q.text).toBe(apiQ.question.text); + expect(q.correctAnswer).toBe(apiQ.correctAnswer); + expect(q.type).toBe('multiple-choice'); + expect(q.category).toBe('General Knowledge'); + expect(q.difficulty).toBe(apiQ.difficulty); + expect(q.options).toContain(apiQ.correctAnswer); + apiQ.incorrectAnswers.forEach((a) => expect(q.options).toContain(a)); + expect(q.options).toHaveLength(4); + }); + + it('throws NetworkError when fetch response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + await expect(provider.fetchQuestions({ count: 5 })).rejects.toMatchObject({ + code: TriviaProviderErrorCode.NetworkError, + }); + }); + + it('throws NetworkError when fetch rejects', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + await expect(provider.fetchQuestions({ count: 5 })).rejects.toMatchObject({ + code: TriviaProviderErrorCode.NetworkError, + }); + }); + + it('throws NoResults when response is empty', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + await expect(provider.fetchQuestions({ count: 5 })).rejects.toMatchObject({ + code: TriviaProviderErrorCode.NoResults, + }); + }); + }); + + // ─── fetchCategories ────────────────────────────────────────────────────── + + describe('fetchCategories', () => { + it('returns exactly 10 categories', async () => { + const cats = await provider.fetchCategories(); + expect(cats).toHaveLength(10); + }); + + it('returns all expected category slugs', async () => { + const cats = await provider.fetchCategories(); + const ids = cats.map((c) => c.id); + expect(ids).toContain('arts_and_literature'); + expect(ids).toContain('general_knowledge'); + expect(ids).toContain('sport_and_leisure'); + }); + + it('makes no network call', async () => { + await provider.fetchCategories(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + // ─── supportsCategories ─────────────────────────────────────────────────── + + describe('supportsCategories', () => { + it('returns true', () => { + expect(provider.supportsCategories()).toBe(true); + }); + }); + + // ─── supportsDifficulty ─────────────────────────────────────────────────── + + describe('supportsDifficulty', () => { + it('returns true', () => { + expect(provider.supportsDifficulty()).toBe(true); + }); + }); + + // ─── resetSession ───────────────────────────────────────────────────────── + + describe('resetSession', () => { + it('is a no-op and does not throw', () => { + expect(() => provider.resetSession()).not.toThrow(); + }); + }); +}); diff --git a/apps/cirquiz/src/providers/thetriviaapi/triviaApiTypes.ts b/apps/cirquiz/src/providers/thetriviaapi/triviaApiTypes.ts new file mode 100644 index 0000000..3353925 --- /dev/null +++ b/apps/cirquiz/src/providers/thetriviaapi/triviaApiTypes.ts @@ -0,0 +1,14 @@ +export interface TriviaApiQuestion { + id: string; + category: string; + difficulty: 'easy' | 'medium' | 'hard'; + correctAnswer: string; + incorrectAnswers: string[]; + question: { text: string }; + type: string; + tags: string[]; + regions: string[]; + isNiche: boolean; +} + +export type TriviaApiResponse = TriviaApiQuestion[]; diff --git a/apps/cirquiz/src/state/__tests__/gameStore.test.ts b/apps/cirquiz/src/state/__tests__/gameStore.test.ts index 2483993..3dc08f4 100644 --- a/apps/cirquiz/src/state/__tests__/gameStore.test.ts +++ b/apps/cirquiz/src/state/__tests__/gameStore.test.ts @@ -1,8 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { router } from 'expo-router'; -import { setProviderForTesting, useGameStore } from '../gameStore'; +import { setProviderForTesting } from '../../providers/providerFactory'; +import { useGameStore } from '../gameStore'; import { Question, TriviaProviderError, TriviaProviderErrorCode } from '../../providers/types'; import { Game, Player, Round } from '../types'; +import { useSettingsStore } from '../settingsStore'; jest.mock('expo-router', () => ({ router: { replace: jest.fn() } })); @@ -17,7 +19,7 @@ const mockProvider = { resetSession: jest.fn(), }; -beforeAll(() => setProviderForTesting(mockProvider)); +beforeAll(() => setProviderForTesting('the-trivia-api', mockProvider)); const makeQuestion = (id: string, correctAnswer = 'A'): Question => ({ id, @@ -477,3 +479,34 @@ describe('Hydrate', () => { expect(state.game).toBeNull(); }); }); + +// ─── Provider source switching ──────────────────────────────────────────────── + +describe('startGame uses provider matching active questionSource', () => { + const questions = [makeQuestion('q1'), makeQuestion('q2'), makeQuestion('q3')]; + + it('uses TheTriviaApiProvider when questionSource is the-trivia-api', async () => { + const ttaMockFetch = jest.fn, [any]>().mockResolvedValue(questions); + const ttaMockProvider = { + fetchQuestions: ttaMockFetch, + fetchCategories: jest.fn().mockResolvedValue([]), + supportsCategories: () => true, + supportsDifficulty: () => true, + resetSession: jest.fn(), + }; + setProviderForTesting('the-trivia-api', ttaMockProvider); + useSettingsStore.setState({ questionSource: 'the-trivia-api' }); + + await useGameStore.getState().startGame({ + players: [{ name: 'Alice', avatar: 'chili' }], + questionCount: 3, + mode: 'quick', + }); + + expect(ttaMockFetch).toHaveBeenCalled(); + expect(mockFetchQuestions).not.toHaveBeenCalled(); + + // Restore + useSettingsStore.setState({ questionSource: 'otdb' }); + }); +}); diff --git a/apps/cirquiz/src/state/__tests__/settingsStore.test.ts b/apps/cirquiz/src/state/__tests__/settingsStore.test.ts new file mode 100644 index 0000000..0248dba --- /dev/null +++ b/apps/cirquiz/src/state/__tests__/settingsStore.test.ts @@ -0,0 +1,59 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSettingsStore } from '../settingsStore'; + +beforeEach(async () => { + await AsyncStorage.clear(); + useSettingsStore.setState({ questionSource: 'the-trivia-api', isHydrated: false }); +}); + +describe('settingsStore', () => { + it('defaults to the-trivia-api', () => { + expect(useSettingsStore.getState().questionSource).toBe('the-trivia-api'); + }); + + it('setQuestionSource updates state', () => { + useSettingsStore.getState().setQuestionSource('the-trivia-api'); + expect(useSettingsStore.getState().questionSource).toBe('the-trivia-api'); + }); + + it('isHydrated is false before rehydration', () => { + expect(useSettingsStore.getState().isHydrated).toBe(false); + }); + + it('isHydrated is true after rehydration', async () => { + await useSettingsStore.persist.rehydrate(); + expect(useSettingsStore.getState().isHydrated).toBe(true); + }); + + it('persisted value is restored on re-init', async () => { + await AsyncStorage.setItem( + '@cirquiz/settings', + JSON.stringify({ + state: { questionSource: 'the-trivia-api' }, + version: 1, + }) + ); + await useSettingsStore.persist.rehydrate(); + expect(useSettingsStore.getState().questionSource).toBe('the-trivia-api'); + }); + + it('migrates settings to defaults when stored version is outdated', async () => { + await AsyncStorage.setItem( + '@cirquiz/settings', + JSON.stringify({ + state: { questionSource: 'the-trivia-api' }, + version: 0, + }) + ); + await useSettingsStore.persist.rehydrate(); + expect(useSettingsStore.getState().questionSource).toBe('the-trivia-api'); + }); + + it('isHydrated is not persisted', async () => { + useSettingsStore.setState({ isHydrated: true }); + const stored = await AsyncStorage.getItem('@cirquiz/settings'); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.state.isHydrated).toBeUndefined(); + }); +}); diff --git a/apps/cirquiz/src/state/gameStore.ts b/apps/cirquiz/src/state/gameStore.ts index b3e99b6..68a07e6 100644 --- a/apps/cirquiz/src/state/gameStore.ts +++ b/apps/cirquiz/src/state/gameStore.ts @@ -4,20 +4,14 @@ import { Alert } from 'react-native'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { getAvatar } from '../avatars'; -import { TriviaQuestionProvider } from '../providers/interface'; -import { OpenTriviaDbProvider } from '../providers/opentdb/OpenTriviaDbProvider'; +import { getProvider } from '../providers/providerFactory'; import { TriviaProviderError } from '../providers/types'; +import { useSettingsStore } from './settingsStore'; import { Game, GameConfig, Player, Round, Turn } from './types'; const STORAGE_KEY = '@cirquiz/active_game'; const CURRENT_SCHEMA_VERSION = 2; -let provider: TriviaQuestionProvider = new OpenTriviaDbProvider(); - -export function setProviderForTesting(p: TriviaQuestionProvider): void { - provider = p; -} - function generateId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } @@ -63,6 +57,7 @@ export const useGameStore = create()( startGame: async (config: GameConfig) => { set({ isLoading: true, pendingConfig: config }); + const provider = getProvider(useSettingsStore.getState().questionSource); provider.resetSession(); try { const questions = await provider.fetchQuestions({ @@ -252,7 +247,9 @@ export const useGameStore = create()( try { const previousQuestionIds = game.rounds.flatMap((r) => r.questions.map((q) => q.id)); - const questions = await provider.fetchQuestions({ + const questions = await getProvider( + useSettingsStore.getState().questionSource + ).fetchQuestions({ count: game.questionCount, category: game.category ?? undefined, difficulty: game.difficulty ?? undefined, diff --git a/apps/cirquiz/src/state/settingsStore.ts b/apps/cirquiz/src/state/settingsStore.ts new file mode 100644 index 0000000..f0e186f --- /dev/null +++ b/apps/cirquiz/src/state/settingsStore.ts @@ -0,0 +1,46 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +const STORAGE_KEY = '@cirquiz/settings'; + +export type QuestionSource = 'otdb' | 'the-trivia-api'; + +interface SettingsStoreState { + questionSource: QuestionSource; + isHydrated: boolean; +} + +interface SettingsStoreActions { + setQuestionSource: (source: QuestionSource) => void; +} + +type SettingsStore = SettingsStoreState & SettingsStoreActions; + +export const useSettingsStore = create()( + persist( + (set) => ({ + questionSource: 'the-trivia-api', + isHydrated: false, + setQuestionSource: (source) => set({ questionSource: source }), + }), + { + name: STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => AsyncStorage), + partialize: ({ isHydrated, ...rest }) => rest, + migrate: (persistedState, fromVersion) => { + // v0 had no schema — treat as empty and fall back to defaults + if (fromVersion < 1) { + return { questionSource: 'the-trivia-api' as QuestionSource }; + } + return persistedState as SettingsStoreState; + }, + onRehydrateStorage: () => (state) => { + if (state) { + state.isHydrated = true; + } + }, + } + ) +); diff --git a/specs/001-pass-and-play-trivia/plan.md b/specs/001-pass-and-play-trivia/plan.md index 9f2feed..e42db76 100644 --- a/specs/001-pass-and-play-trivia/plan.md +++ b/specs/001-pass-and-play-trivia/plan.md @@ -54,7 +54,7 @@ specs/001-pass-and-play-trivia/ cirquiz/ ├── package.json # yarn workspaces root: workspaces: ["apps/*"] ├── apps/ -│ └── mobile/ # Expo React Native app +│ └── cirquiz/ # Expo React Native app │ ├── app/ # Expo Router — file-based screens │ │ ├── _layout.tsx # Root layout (navigation container, i18n init) │ │ ├── index.tsx # Home: New Game / Resume Game @@ -69,21 +69,27 @@ cirquiz/ │ │ ├── standings.tsx # Final standings: CUMULATIVE scores, Play Another Round, Go Home │ │ └── error.tsx # API error screen (question fetch failure) │ ├── src/ +│ │ ├── avatars.ts # Avatar definitions +│ │ ├── theme.ts # Design tokens (colors, spacing, typography) │ │ ├── providers/ # TriviaQuestionProvider interface + implementations │ │ │ ├── index.ts # Re-exports interface, types, and OpenTriviaDbProvider │ │ │ ├── interface.ts # TriviaQuestionProvider interface │ │ │ ├── types.ts # Question, Category, QuestionFetchParams, TriviaProviderError, enums │ │ │ └── opentdb/ -│ │ │ ├── OpenTriviaDbProvider.ts # Concrete OTDB implementation -│ │ │ └── otdbTypes.ts # Internal OTDB API response shapes +│ │ │ ├── OpenTriviaDbProvider.ts # Concrete OTDB implementation +│ │ │ ├── otdbTypes.ts # Internal OTDB API response shapes +│ │ │ └── __tests__/OpenTriviaDbProvider.test.ts │ │ ├── components/ # Custom RN components (no design library, base RN stylesheets) │ │ ├── state/ # Zustand store + AsyncStorage persist adapter (see §Store Shape) +│ │ │ ├── gameStore.ts +│ │ │ ├── types.ts +│ │ │ └── __tests__/gameStore.test.ts │ │ ├── i18n/ # en.json strings; i18next + expo-localization init │ │ ├── hooks/ # useGame, useCurrentPlayer, useCurrentQuestion, etc. -│ │ └── utils/ # htmlDecode (he library), shuffle (Fisher-Yates), uuid (crypto.randomUUID) -│ ├── __tests__/ +│ │ └── utils/ # htmlDecode (he library), shuffle (Fisher-Yates), color helpers +│ │ └── __tests__/ # Co-located unit tests │ ├── metro.config.js # Standard default config (no watchFolders needed; provider is inside the app) -│ ├── app.config.js # Dynamic config (replaces app.json); reads APP_ENV +│ ├── app.config.ts # Dynamic config (replaces app.json); reads APP_ENV │ └── eas.json # Run `eas build` from apps/cirquiz/, not workspace root └── specs/ ``` diff --git a/specs/001-pass-and-play-trivia/spec.md b/specs/001-pass-and-play-trivia/spec.md index 0b76fe0..83fddd4 100644 --- a/specs/001-pass-and-play-trivia/spec.md +++ b/specs/001-pass-and-play-trivia/spec.md @@ -2,7 +2,7 @@ **Feature Branch**: `001-pass-and-play-trivia` **Created**: 2026-03-07 -**Status**: Draft +**Status**: Complete **Input**: User description: "Develop a local, pass-and-play multiplayer trivia app..." ## User Scenarios & Testing *(mandatory)* diff --git a/specs/002-provider-settings/checklists/requirements.md b/specs/002-provider-settings/checklists/requirements.md new file mode 100644 index 0000000..0d31678 --- /dev/null +++ b/specs/002-provider-settings/checklists/requirements.md @@ -0,0 +1,30 @@ +# Specification Quality Checklist: Question Source Selection + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-11 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification diff --git a/specs/002-provider-settings/data-model.md b/specs/002-provider-settings/data-model.md new file mode 100644 index 0000000..3b9dc91 --- /dev/null +++ b/specs/002-provider-settings/data-model.md @@ -0,0 +1,140 @@ +# Data Model: Question Source Selection + +**Branch**: `002-provider-settings` | **Date**: 2026-03-11 + +--- + +## New Entities + +### QuestionSource (enum / union type) + +A string literal union representing the two supported question providers. + +```typescript +// apps/cirquiz/src/state/settingsStore.ts +export type QuestionSource = 'otdb' | 'the-trivia-api'; +``` + +| Value | Description | +|-------|-------------| +| `'otdb'` | Open Trivia Database (existing provider, default) | +| `'the-trivia-api'` | The Trivia API | + +**Validation rules**: +- Must be one of the two values above. +- Default for new installs: `'otdb'`. + +--- + +### AppSettings (persisted store state) + +The user preference record. Device-local, not synced. + +```typescript +// apps/cirquiz/src/state/settingsStore.ts +interface SettingsStoreState { + questionSource: QuestionSource; // Active question provider + isHydrated: boolean; // AsyncStorage rehydration flag (not persisted) +} +``` + +**Persistence**: +- AsyncStorage key: `@cirquiz/settings` +- Schema version: `1` +- Partialised: `isHydrated` is excluded from persistence (same pattern as `gameStore`) +- Excluded from persistence: `isHydrated` + +**State transitions**: +``` +[app start] → isHydrated: false + ↓ AsyncStorage rehydrates +[hydrated] → isHydrated: true, questionSource: stored value (or 'otdb' if none) + +[user selects source] → questionSource: new value (written immediately to AsyncStorage) +``` + +--- + +## Modified Entities + +### Game (no schema change) + +The `Game` entity in `state/types.ts` is **unchanged**. The question source active at game-start is implicitly captured by the questions themselves (each `Question` carries its own `category` and `difficulty` fields). No `questionSource` field is added to `Game` — the spec states that a game in progress is unaffected by subsequent source changes (FR-011), and the active provider is resolved at the start of each game, not stored in the game record. + +### GameConfig (no schema change) + +`GameConfig` in `state/types.ts` is **unchanged**. The provider is resolved from the settings store at `startGame` call time, not passed through config. + +--- + +## Provider Abstractions (new files, not persisted) + +### TheTriviaApiProvider + +```typescript +// apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts +class TheTriviaApiProvider implements TriviaQuestionProvider { + fetchQuestions(params: QuestionFetchParams): Promise + fetchCategories(): Promise + supportsCategories(): boolean // → true + supportsDifficulty(): boolean // → true + resetSession(): void // → no-op (stateless API) +} +``` + +**Category list** (hardcoded, 10 entries): + +| `Category.id` | `Category.name` | +|---------------|----------------| +| `arts_and_literature` | Arts & Literature | +| `film_and_tv` | Film & TV | +| `food_and_drink` | Food & Drink | +| `general_knowledge` | General Knowledge | +| `geography` | Geography | +| `history` | History | +| `music` | Music | +| `science` | Science | +| `society_and_culture` | Society & Culture | +| `sport_and_leisure` | Sport & Leisure | + +**Question mapping** (from The Trivia API v2 response): + +| API field | `Question` field | Transform | +|-----------|-----------------|-----------| +| `id` | `id` | Direct | +| `question.text` | `text` | `q.question.text` | +| `correctAnswer` | `correctAnswer` | Direct | +| `incorrectAnswers` | `options` | `shuffle([correctAnswer, ...incorrectAnswers])` | +| `difficulty` | `difficulty` | Direct | +| `category` | `category` | Direct (slug string) | +| *(none)* | `type` | Always `'multiple-choice'` | + +### Provider Factory + +```typescript +// apps/cirquiz/src/providers/providerFactory.ts +function getProvider(source: QuestionSource): TriviaQuestionProvider +``` + +Returns a singleton instance per source key. Instances are created once and cached in module scope (matching the existing pattern in `gameStore.ts`). + +--- + +## Relationships + +``` +SettingsStore + └─ questionSource: QuestionSource + │ + ▼ (resolved at call time in gameStore actions) + providerFactory.getProvider(source) + │ + ▼ + TriviaQuestionProvider ◄─── OpenTriviaDbProvider (source: 'otdb') + ◄─── TheTriviaApiProvider (source: 'the-trivia-api') + │ + ▼ + Question[] → Round → Game (gameStore) +``` + +The settings store and the game store are **not** directly linked — the factory is the mediating layer resolved imperatively at action call time. diff --git a/specs/002-provider-settings/plan.md b/specs/002-provider-settings/plan.md new file mode 100644 index 0000000..570adb0 --- /dev/null +++ b/specs/002-provider-settings/plan.md @@ -0,0 +1,87 @@ +# Implementation Plan: Question Source Selection + +**Branch**: `002-provider-settings` | **Date**: 2026-03-11 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/002-provider-settings/spec.md` + +## Summary + +Add The Trivia API as a second question source alongside the existing Open Trivia Database, with a persisted user setting to select between them and a dedicated settings screen accessible from the home and setup screens. The existing `TriviaQuestionProvider` interface is extended with a second concrete implementation; a lightweight settings Zustand store persists the active source via AsyncStorage; and the game store resolves the active provider at runtime from the settings store. + +## Technical Context + +**Language/Version**: TypeScript ~5.9 +**Primary Dependencies**: React Native 0.83.2, Expo SDK ~55, Expo Router ~55, Zustand ^5, @react-native-async-storage/async-storage, react-native-reanimated, i18next + react-i18next +**Storage**: AsyncStorage — `@cirquiz/settings` (new) and `@cirquiz/active_game` (existing) +**Testing**: Jest (existing test runner inferred from `__tests__/` directories and jest config in `apps/cirquiz/`) +**Target Platform**: iOS + Android (React Native / Expo) +**Project Type**: mobile-app +**Performance Goals**: Settings screen renders instantly (static content); question fetch target <2s, consistent with existing behavior +**Constraints**: Free tier of The Trivia API — no API key, stateless (no server-side sessions), non-commercial use only; device-local settings, no cloud sync +**Scale/Scope**: Single device, two question sources, one settings preference + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +| Principle | Assessment | Status | +|-----------|------------|--------| +| **I. Code Quality** | New `TheTriviaApiProvider` mirrors the existing `OpenTriviaDbProvider` pattern. Provider selection extracted to a factory. `useCategoryLoader` updated to use the active provider instead of importing OTDB directly (removes current coupling). No duplication introduced. Lint/format gates enforced. | ✅ PASS | +| **II. Testing Standards** | US1 (P1): at least one automated test for the happy-path journey of switching sources and starting a game. Written alongside implementation. New provider unit-tested analogously to `OpenTriviaDbProvider.test.ts`. | ✅ PASS | +| **III. UX Consistency** | Settings screen uses `GradientScreen`, `SelectableRow`, and `IconButton` — same components used elsewhere. Navigation uses standard Expo Router `router.push/back()` stack behavior. Toast for category reset follows the spec's "brief notice" requirement — no bespoke UI patterns introduced. | ✅ PASS | +| **IV. Performance** | Settings screen is static; no async data fetch. Provider is resolved at call time, not in a blocking render path. No N+1 fetching: categories are still fetched once on demand. Existing question-fetch flow is unchanged. | ✅ PASS | + +*No constitution violations. Complexity Tracking section not required.* + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-provider-settings/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +apps/cirquiz/ +├── app/ +│ ├── _layout.tsx # No change (settings screen auto-registered) +│ ├── index.tsx # Add gear IconButton → router.push('/settings') +│ ├── setup.tsx # Add gear IconButton + observe source changes +│ └── settings.tsx # NEW: dedicated settings screen +│ +└── src/ + ├── providers/ + │ ├── interface.ts # Unchanged + │ ├── types.ts # Unchanged + │ ├── index.ts # Export TheTriviaApiProvider + │ ├── providerFactory.ts # NEW: getProvider(source) — singleton per source + │ ├── opentdb/ # Unchanged + │ │ └── OpenTriviaDbProvider.ts + │ └── thetriviaapi/ # NEW + │ ├── TheTriviaApiProvider.ts + │ ├── triviaApiTypes.ts + │ └── __tests__/ + │ └── TheTriviaApiProvider.test.ts + │ + ├── state/ + │ ├── gameStore.ts # Resolve provider via providerFactory at call time + │ └── settingsStore.ts # NEW: QuestionSource + AsyncStorage persistence + │ + ├── hooks/ + │ └── useCategoryLoader.ts # Update: load from active provider, not hardcoded OTDB + │ + └── i18n/ + └── en.json # Add settings.* translation keys +``` + +**Structure Decision**: Single mobile-app layout (Option 3). All changes are co-located within `apps/cirquiz/`. No new packages or build targets required. Provider hierarchy extends the existing `providers/` directory, matching the established `opentdb/` pattern. + +## Complexity Tracking + +> No violations to justify. Constitution check passed without exceptions. diff --git a/specs/002-provider-settings/quickstart.md b/specs/002-provider-settings/quickstart.md new file mode 100644 index 0000000..9fea7f5 --- /dev/null +++ b/specs/002-provider-settings/quickstart.md @@ -0,0 +1,173 @@ +# Quickstart: Question Source Selection + +**Branch**: `002-provider-settings` | **Date**: 2026-03-11 + +--- + +## What This Feature Adds + +- A new question provider: **The Trivia API** (`https://the-trivia-api.com/v2/questions`) +- A **Settings screen** (`/settings`) accessible from the home and setup screens +- A **persisted preference** (`@cirquiz/settings`) that remembers the active question source across app restarts +- Category list that updates to reflect the active provider + +--- + +## File Map + +| What | Where | +|------|-------| +| New settings screen | `apps/cirquiz/app/settings.tsx` | +| Settings Zustand store | `apps/cirquiz/src/state/settingsStore.ts` | +| Provider factory | `apps/cirquiz/src/providers/providerFactory.ts` | +| New provider implementation | `apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts` | +| New provider response types | `apps/cirquiz/src/providers/thetriviaapi/triviaApiTypes.ts` | +| New provider tests | `apps/cirquiz/src/providers/thetriviaapi/__tests__/TheTriviaApiProvider.test.ts` | +| Modified: game store | `apps/cirquiz/src/state/gameStore.ts` | +| Modified: category loader hook | `apps/cirquiz/src/hooks/useCategoryLoader.ts` | +| Modified: home screen | `apps/cirquiz/app/index.tsx` | +| Modified: setup screen | `apps/cirquiz/app/setup.tsx` | +| Modified: provider index | `apps/cirquiz/src/providers/index.ts` | +| Modified: i18n strings | `apps/cirquiz/src/i18n/en.json` | + +--- + +## Key Implementation Notes + +### 1. Settings Store (`settingsStore.ts`) + +Follows the `gameStore.ts` pattern exactly: + +```typescript +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +export type QuestionSource = 'otdb' | 'the-trivia-api'; + +// Persist key: '@cirquiz/settings' +// Default: questionSource = 'otdb' +// Excluded from persistence: isHydrated +``` + +### 2. Provider Factory (`providerFactory.ts`) + +```typescript +// Returns a singleton per source — instances are module-scoped. +// Called in gameStore.ts actions at call time: +// const p = getProvider(useSettingsStore.getState().questionSource); +// p.resetSession(); +// await p.fetchQuestions(params); +``` + +### 3. Game Store Changes (`gameStore.ts`) + +Replace the top-level `let provider = new OpenTriviaDbProvider()` and `setProviderForTesting`: +- Production: `getProvider(useSettingsStore.getState().questionSource)` called inside `startGame`, `retryFetch`, `startNextRound` +- Testing: `providerFactory` exports a `setProviderForTesting(source, p)` override, or the existing test injection pattern is preserved via a separate export + +### 4. The Trivia API Provider (`TheTriviaApiProvider.ts`) + +```typescript +const BASE_URL = 'https://the-trivia-api.com/v2'; + +// fetchQuestions: GET /questions?limit={count}&categories={category}&difficulties={difficulty} +// fetchCategories: returns hardcoded 10-item array (no network call) +// supportsCategories: true +// supportsDifficulty: true +// resetSession: no-op +``` + +No HTML decoding needed (API returns plain text). +No session token management (stateless API on free tier). + +### 5. Settings Screen (`settings.tsx`) + +- Uses `GradientScreen` + `SelectableRow` (existing components) +- Two rows: "Open Trivia Database" and "The Trivia API" +- Active source shown with a selected indicator (same pattern as `CategorySelector` / `DifficultySelector`) +- Back navigation: `router.back()` (returns to caller — home or setup) +- Gear `IconButton` added to home and setup screens + +### 6. Category Reset in Setup (`setup.tsx`) + +```typescript +const questionSource = useSettingsStore((s) => s.questionSource); + +useEffect(() => { + if (selectedCategory !== null) { + setSelectedCategory(null); + // show brief toast/notice + } +}, [questionSource]); +``` + +The effect fires when the user returns from settings with a changed source. +The notice must be non-blocking (not `Alert`) — use a brief animated overlay via `react-native-reanimated` or a cross-platform toast utility if one exists in the codebase. + +### 7. `useCategoryLoader` Refactor + +```typescript +// Before (hardcoded): +setCategories(await new OpenTriviaDbProvider().fetchCategories()); + +// After (source-aware): +const source = useSettingsStore.getState().questionSource; +setCategories(await getProvider(source).fetchCategories()); +``` + +--- + +## i18n Keys Added + +```json +"settings": { + "title": "Settings", + "questionSource": "Question Source", + "otdb": "Open Trivia Database", + "theTriviaApi": "The Trivia API", + "categoryResetNotice": "Category selection was reset because the question source changed." +} +``` + +--- + +## Testing Approach + +| Test | Location | Covers | +|------|----------|--------| +| `TheTriviaApiProvider` unit test | `thetriviaapi/__tests__/TheTriviaApiProvider.test.ts` | HTTP fetch, response mapping, error codes, `fetchCategories`, `supportsCategories`, `supportsDifficulty`, `resetSession` | +| Settings store unit test | `state/__tests__/settingsStore.test.ts` | Default value, `setQuestionSource`, persistence | +| Game store: source switching | `state/__tests__/gameStore.test.ts` | `startGame` uses the provider matching the active `questionSource` | + +P1 happy-path test: switch `questionSource` to `'the-trivia-api'`, call `startGame`, assert questions come from `TheTriviaApiProvider` (using the existing `setProviderForTesting` pattern or the factory override). + +--- + +## Navigation Flow + +``` +Home (index.tsx) + ├── [gear icon] → router.push('/settings') → Settings (/settings) + │ └── router.back() → Home + └── [New Game] → Setup (/setup) + ├── [gear icon] → router.push('/settings') → Settings (/settings) + │ └── router.back() → Setup + └── [Start Game] → /(game)/... +``` + +The `(game)` group screens have no settings entry point — FR-005 satisfied by omission. + +--- + +## Quality Gate Checklist + +Before committing, run from the monorepo root: + +```sh +yarn workspace cirquiz lint +yarn workspace cirquiz format:check +yarn workspace cirquiz typecheck +``` + +All three must pass with zero errors. diff --git a/specs/002-provider-settings/research.md b/specs/002-provider-settings/research.md new file mode 100644 index 0000000..0f87b3f --- /dev/null +++ b/specs/002-provider-settings/research.md @@ -0,0 +1,159 @@ +# Research: Question Source Selection + +**Branch**: `002-provider-settings` | **Date**: 2026-03-11 + +--- + +## 1. The Trivia API — Integration Design + +**Decision**: Implement `TheTriviaApiProvider` using The Trivia API v2 (`https://the-trivia-api.com/v2/questions`). + +**Rationale**: The API is free, requires no API key, and returns well-structured multiple-choice questions. Its response shape is a direct array of question objects, making it straightforward to adapt to the existing `TriviaQuestionProvider` interface. + +**Alternatives considered**: Using a different third-party API. Rejected — The Trivia API was explicitly specified by the user and the spec. + +### API Details + +**Questions endpoint**: `GET https://the-trivia-api.com/v2/questions` + +Query parameters: +| Parameter | Type | Notes | +|-----------|------|-------| +| `limit` | number | Number of questions (default 10, max per request varies) | +| `categories` | string | Category slug (e.g. `science`, `film_and_tv`) | +| `difficulties` | string | `easy`, `medium`, or `hard` | + +**Response shape** (array of): +```json +{ + "id": "622a1c3d7fce235dd0f05b61", + "category": "general_knowledge", + "difficulty": "easy", + "correctAnswer": "Paris", + "incorrectAnswers": ["London", "Berlin", "Rome"], + "question": { "text": "What is the capital of France?" }, + "type": "text_choice", + "tags": [...], + "regions": [...], + "isNiche": false +} +``` + +Field mapping to internal `Question` type: +| API field | Internal field | Transform | +|-----------|---------------|-----------| +| `id` | `id` | Direct | +| `question.text` | `text` | `q.question.text` | +| `correctAnswer` | `correctAnswer` | Direct | +| `incorrectAnswers` | `options` | `shuffle([correctAnswer, ...incorrectAnswers])` | +| `difficulty` | `difficulty` | Direct (`easy`/`medium`/`hard` match) | +| `category` | `category` | Direct (slug used as display label) | +| *(no type field needed)* | `type` | Always `'multiple-choice'` | + +No HTML entity decoding needed — the API returns plain text. + +### Available Categories (10) + +| API slug | Display name | +|----------|-------------| +| `arts_and_literature` | Arts & Literature | +| `film_and_tv` | Film & TV | +| `food_and_drink` | Food & Drink | +| `general_knowledge` | General Knowledge | +| `geography` | Geography | +| `history` | History | +| `music` | Music | +| `science` | Science | +| `society_and_culture` | Society & Culture | +| `sport_and_leisure` | Sport & Leisure | + +These are hardcoded in `TheTriviaApiProvider.fetchCategories()` (same pattern as OTDB). + +### Difficulty and Type Support + +- Difficulty: `easy`, `medium`, `hard` — same values as OTDB, no mapping needed. +- Question type: All questions are multiple-choice (3 incorrect + 1 correct). The Trivia API does not provide true/false questions on the free tier. `supportsDifficulty()` → `true`, `supportsCategories()` → `true`. +- Type field on the setup screen: When The Trivia API is selected, the true/false question type option is naturally absent (the provider only returns `multiple-choice`). No special warning UI needed per the spec's assumptions. + +### Session / Deduplication + +**Decision**: Accept possible repeats across rounds; `resetSession()` is a no-op. + +**Rationale**: The Trivia API session tokens (for server-side deduplication) are a paid feature. The free tier has a large enough question pool that duplicates are very unlikely for typical game sizes (5–20 questions). The `excludeIds` field in `QuestionFetchParams` is used by neither provider for actual server-side filtering; OTDB achieves dedup via its token/pool mechanism. For The Trivia API, we accept the same behaviour as OTDB when a token expires — questions may repeat rarely. + +**Alternatives considered**: Client-side over-fetch and ID-based filtering. Rejected — adds complexity for a rare edge case and wastes API budget. + +--- + +## 2. Provider Selection Architecture + +**Decision**: Module-level singleton factory (`providerFactory.ts`) + runtime resolution in `gameStore.ts`. + +**Rationale**: The `gameStore.ts` already has a module-level `let provider = new OpenTriviaDbProvider()`. The cleanest extension is a `getProvider(source)` factory that returns a cached singleton per source. `gameStore.ts` calls `getActiveProvider()` at action call time (not at module load), which reads the current settings from `useSettingsStore.getState()`. This keeps the stores fully decoupled — settings store doesn't know about the game store, and the game store only calls into the factory at the moment questions are needed. + +**Alternatives considered**: +1. Subscribe game store to settings store changes. Rejected — subscription overhead for a preference that changes rarely and only outside a game. +2. Pass the provider as a parameter to `startGame`. Rejected — would require changing the `GameConfig` interface and all callers for an internal implementation detail. +3. Keep the existing `setProviderForTesting` pattern and extend it. Rejected — that function is test-only infrastructure; mixing it with production routing logic would be misleading. + +--- + +## 3. Settings Persistence + +**Decision**: New Zustand store (`settingsStore.ts`) with AsyncStorage persistence at key `@cirquiz/settings`. + +**Rationale**: Follows the exact same pattern as `gameStore.ts`. Keeps settings concerns separate from game state. The two stores are independently rehydrated so settings are available immediately on app start. + +**Default**: `questionSource: 'otdb'` — preserves existing behavior for new installs (FR-008). + +**Schema version**: Start at `1`. No migration needed initially. + +**Alternatives considered**: Storing settings inside `gameStore`. Rejected — settings are user preferences that outlive any individual game; mixing them into game state would complicate persistence partitioning and schema migration. + +--- + +## 4. Settings Screen Navigation + +**Decision**: `app/settings.tsx` as a standard Expo Router file-based route; navigated via `router.push('/settings')` from index and setup. + +**Rationale**: `router.push` preserves the navigation stack, so `router.back()` always returns to the calling screen (home or setup). No custom back-target logic needed. The screen is only reachable by user-initiated `router.push` — game screens don't call it, satisfying FR-005. + +**Alternatives considered**: Modal/sheet. Rejected by spec — FR-002 explicitly requires a dedicated navigable screen with back navigation. + +--- + +## 5. Category Reset on Source Change + +**Decision**: `setup.tsx` observes `questionSource` from the settings store via `useSettingsStore`. When the value changes while the setup screen is mounted, clear the locally selected category and show a toast notification. + +**Rationale**: The simplest reliable pattern. Expo Router's stack means the setup screen stays mounted while the user navigates to settings and back; the value change is detectable via a `useEffect` on `questionSource`. The toast matches the "brief notice" requirement from the spec (FR-010). + +**Toast implementation**: React Native's built-in `ToastAndroid` is Android-only. For cross-platform, the existing codebase will be checked for any toast utility. If none exists, use a lightweight approach: brief `Alert` or a custom animated overlay using `react-native-reanimated`. Prefer a non-blocking approach (not `Alert`) to match "brief notice" UX intent. + +**Alternatives considered**: Detecting source change in the settings screen and passing back a signal via navigation params. Rejected — Expo Router's stack-based back navigation doesn't have a built-in "result" mechanism; the settings store observation is simpler and more reactive. + +--- + +## 6. `useCategoryLoader` Refactor + +**Decision**: Update `useCategoryLoader` to accept the active provider (or source key) and load categories from it, removing the hardcoded `new OpenTriviaDbProvider()`. + +**Rationale**: Current implementation imports `OpenTriviaDbProvider` directly and instantiates a throwaway object. With two providers, the hook must use the active provider to load the correct category list. The hook will call `getProvider(questionSource)` from the factory or accept the provider as an argument. + +--- + +## 7. i18n Keys Needed + +New keys to add to `en.json` under a `settings` namespace: + +```json +"settings": { + "title": "Settings", + "questionSource": "Question Source", + "otdb": "Open Trivia Database", + "theTriviaApi": "The Trivia API", + "categoryResetNotice": "Category selection was reset because the question source changed." +} +``` + +The `categoryResetNotice` string is displayed as the toast when the source change clears a selected category. diff --git a/specs/002-provider-settings/spec.md b/specs/002-provider-settings/spec.md new file mode 100644 index 0000000..e32c065 --- /dev/null +++ b/specs/002-provider-settings/spec.md @@ -0,0 +1,113 @@ +# Feature Specification: Question Source Selection + +**Feature Branch**: `002-provider-settings` +**Created**: 2026-03-11 +**Status**: Draft +**Input**: User description: "I want to support https://the-trivia-api.com/docs/#section/Introduction in addition to otdb. I want users to be able to choose between these two question sources. They can do this from a setting menu. The settings are available on the index and the setup screen, but not within the game itself." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Select a Question Source (Priority: P1) + +A user wants to switch the trivia question source from the default (Open Trivia Database) to The Trivia API. They open the settings menu from the home screen, choose The Trivia API, and start a game — questions come from the newly selected source. + +**Why this priority**: This is the entire value of the feature. Without the ability to choose a source, nothing else matters. + +**Independent Test**: Open the app, access settings from the home screen, switch the question source to The Trivia API, start a new game, and verify that questions are successfully fetched and the game runs to completion. + +**Acceptance Scenarios**: + +1. **Given** the home screen is visible, **When** the user taps the settings control, **Then** the app navigates to a dedicated settings screen showing the currently active question source. +2. **Given** the settings screen is open, **When** the user selects a different question source, **Then** the selection is confirmed and the settings screen reflects the new selection. +3. **Given** The Trivia API is selected, **When** the user starts a new game with Quick Play, **Then** questions are fetched from The Trivia API and the game proceeds normally. +4. **Given** Open Trivia Database is selected, **When** the user starts a new game, **Then** questions are fetched from OTDB and the game proceeds normally (existing behavior preserved). +5. **Given** the settings screen is accessible from the home screen, **When** the user navigates to game setup, **Then** the same settings entry point is also available from the setup screen. +6. **Given** the user is on the settings screen, **When** they tap back, **Then** they are returned to the screen they came from (home or setup). + +--- + +### User Story 2 - Source Preference Persists (Priority: P2) + +A user selects The Trivia API as their preferred source, plays a game, and later closes the app. When they reopen the app, their source preference is remembered — they do not need to reconfigure it each session. + +**Why this priority**: Without persistence, users must reconfigure their preference every launch, which is frustrating. However, the selection feature (US1) delivers value even without persistence. + +**Independent Test**: Select The Trivia API in settings, fully close the app, reopen it, and verify the settings menu still shows The Trivia API as the active source without any user action. + +**Acceptance Scenarios**: + +1. **Given** a user has selected The Trivia API, **When** the app is fully closed and reopened, **Then** The Trivia API remains the active question source. +2. **Given** a user has never changed the setting, **When** the app is opened for the first time or after a fresh install, **Then** the default source (Open Trivia Database) is active. + +--- + +### User Story 3 - Setup Adapts to Selected Source (Priority: P3) + +The game setup screen reflects the capabilities of the currently selected question source. When a source is selected that has different categories, the category list in setup updates to show only what the active source supports. + +**Why this priority**: Improves setup quality and prevents configuration mismatches, but the game remains fully playable without this (both sources handle unknown or empty category gracefully). + +**Independent Test**: Select The Trivia API, navigate to game setup and open the category selector — verify only The Trivia API's categories are shown. Switch back to OTDB and verify the list updates. + +**Acceptance Scenarios**: + +1. **Given** The Trivia API is the active source, **When** a user opens the category selector in game setup, **Then** only The Trivia API's available categories are shown. +2. **Given** OTDB is the active source, **When** a user opens the category selector in game setup, **Then** OTDB's categories are shown (existing behavior). +3. **Given** a user changes the question source from the setup screen, **When** they return to the setup form, **Then** the category options reflect the newly selected source and any previously chosen category is cleared. + +--- + +### Edge Cases + +- What if a user changes the question source while the setup screen is open? — Categories MUST reset to the new source's options; any previously selected category MUST be cleared, and a brief notice MUST be shown to the user indicating that their category selection was reset. +- What if the selected source is unavailable at game start? — The existing error flow applies (user-readable error, game does not start); the source setting is unaffected. +- What if a user opens settings from the setup screen, changes the source, and navigates back? — The setup screen updates to reflect the new source; the user returns to setup via back navigation on the settings screen. +- What if both sources are unavailable at the same time? — Each source fails independently; the error reflects the currently active source's failure only. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The app MUST support two question sources: Open Trivia Database (existing) and The Trivia API. +- **FR-002**: Users MUST be able to select their preferred question source via a dedicated settings screen (a separate navigable route, not a modal or inline control). +- **FR-003**: The settings screen MUST be accessible from the home screen. +- **FR-004**: The settings screen MUST be accessible from the game setup screen. +- **FR-005**: The settings screen MUST NOT be accessible from within an active game (handoff, question, reveal, standings, or error screens). +- **FR-006**: The currently active question source MUST be clearly displayed in the settings screen so users always know which source is selected. +- **FR-007**: The selected question source MUST be persisted locally and survive app close and reopen. +- **FR-012**: The settings screen MUST provide back navigation to return to whichever screen the user came from (home or setup). +- **FR-008**: The default question source for a new install MUST be Open Trivia Database (preserving existing behavior). +- **FR-009**: When the active source is changed, the category list in game setup MUST update to reflect the new source's available categories. +- **FR-010**: When the active source is changed while the setup screen is open, any previously selected category MUST be cleared and a brief notice MUST be shown informing the user that their category selection was reset. +- **FR-011**: The selected question source applies to all new games started after the change; any game already in progress is unaffected. + +### Key Entities + +- **AppSettings**: A persisted, device-local record of user preferences. Initially holds a single field: the selected question source. Not synced across devices. +- **QuestionSource**: An enumerated choice representing a supported trivia data source (Open Trivia Database or The Trivia API). Determines which provider fetches questions and which categories are available in setup. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can switch question sources and complete a full game using the newly selected source with no errors under normal conditions. +- **SC-002**: The selected question source is correctly restored after app close and reopen in 100% of tested cases. +- **SC-003**: The settings entry point is reachable in 2 taps or fewer from both the home screen and the setup screen. +- **SC-004**: When the source is changed on the setup screen, the category list visibly updates before the user can tap Start. +- **SC-005**: The settings menu is unreachable from any in-game screen (handoff, question, reveal, standings, error) in 100% of tested configurations. + +## Clarifications + +### Session 2026-03-11 + +- Q: How is the settings UI presented — modal/sheet, dedicated screen, or inline? → A: Dedicated settings screen (new navigable route with back navigation). +- Q: When a source change clears a previously selected category in setup, should the user be notified? → A: Yes — show a brief notice (toast) informing the user that their category selection was reset. + +## Assumptions + +- Open Trivia Database remains the default; its existing behavior is fully preserved when it is the active source. +- The Trivia API requires no API key for the question volumes typical of this app (free tier is sufficient). +- The Trivia API does not support True/False questions; when it is the active source, games consist of multiple-choice questions only. This is an acceptable limitation and does not require a special warning beyond the natural absence of that option in setup. +- Both sources share the same difficulty level values (easy / medium / hard), so the difficulty selector does not change when the source changes. +- Settings are device-local; no user account or cloud sync is involved. +- The settings UI is a dedicated navigable screen — no per-source advanced configuration is required. diff --git a/specs/002-provider-settings/tasks.md b/specs/002-provider-settings/tasks.md new file mode 100644 index 0000000..f844f42 --- /dev/null +++ b/specs/002-provider-settings/tasks.md @@ -0,0 +1,163 @@ +# Tasks: Question Source Selection + +**Input**: Design documents from `/specs/002-provider-settings/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no blocking dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) +- All paths are relative to the monorepo root + +--- + +## Phase 1: Setup + +No project initialization needed — all dependencies (`@react-native-async-storage/async-storage`, `zustand`, `react-native-reanimated`, etc.) are already installed. No new packages required. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: New provider implementation and factory — required by all three user stories before any story work can begin. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T001 [P] Create `apps/cirquiz/src/providers/thetriviaapi/triviaApiTypes.ts` with TypeScript types for The Trivia API v2 response shape (`TriviaApiQuestion`, `TriviaApiResponse`) +- [X] T002 Implement `apps/cirquiz/src/providers/thetriviaapi/TheTriviaApiProvider.ts` — `fetchQuestions` (GET `/v2/questions` with `limit`, `categories`, `difficulties` params + response mapping), `fetchCategories` (hardcoded 10-entry array), `supportsCategories` → `true`, `supportsDifficulty` → `true`, `resetSession` → no-op +- [X] T003 Write `apps/cirquiz/src/providers/thetriviaapi/__tests__/TheTriviaApiProvider.test.ts` — unit tests covering HTTP fetch and response mapping, `fetchCategories` returns all 10 categories, `supportsCategories`/`supportsDifficulty` return `true`, `resetSession` is a no-op, error handling for failed fetch +- [X] T004 Create `apps/cirquiz/src/providers/providerFactory.ts` with `getProvider(source: QuestionSource): TriviaQuestionProvider` — module-scoped singleton per source key; export `setProviderForTesting(source, provider)` for test injection +- [X] T005 [P] Update `apps/cirquiz/src/providers/index.ts` to export `TheTriviaApiProvider` and `getProvider` + +**Checkpoint**: Foundation ready — `TheTriviaApiProvider` and `getProvider` factory are available for all user story phases. + +--- + +## Phase 3: User Story 1 — Select a Question Source (Priority: P1) 🎯 MVP + +**Goal**: Users can open a settings screen from the home or setup screen, switch between Open Trivia Database and The Trivia API, and the selected source is used for all subsequent games. + +**Independent Test**: Open the app, access settings from the home screen, switch the question source to The Trivia API, start a new game, and verify that questions are successfully fetched and the game runs to completion. + +### Implementation for User Story 1 + +- [X] T006 [P] [US1] Add `settings.*` translation keys to `apps/cirquiz/src/i18n/en.json` — `title`, `questionSource`, `otdb`, `theTriviaApi`, `categoryResetNotice` +- [X] T007 [US1] Create `apps/cirquiz/src/state/settingsStore.ts` — export `QuestionSource` type (`'otdb' | 'the-trivia-api'`), create Zustand store with `questionSource: QuestionSource` (default `'otdb'`) and `setQuestionSource` action (in-memory only; persistence added in US2) +- [X] T008 [US1] Update `apps/cirquiz/src/state/gameStore.ts` — remove module-level `let provider = new OpenTriviaDbProvider()`; resolve provider via `getProvider(useSettingsStore.getState().questionSource)` at call time inside `startGame`, `retryFetch`, and `startNextRound` +- [X] T009 [US1] Update `apps/cirquiz/src/state/__tests__/gameStore.test.ts` — add test: call `setProviderForTesting('the-trivia-api', mockProvider)`, set `questionSource` to `'the-trivia-api'`, call `startGame`, assert mock `TheTriviaApiProvider` was used to fetch questions +- [X] T010 [US1] Create `apps/cirquiz/app/settings.tsx` — dedicated settings screen using `GradientScreen` + `SelectableRow` for OTDB and The Trivia API rows, active source indicator (checked/selected state), calls `setQuestionSource` on tap, back navigation via `router.back()` +- [X] T011 [P] [US1] Add gear `IconButton` to `apps/cirquiz/app/index.tsx` that calls `router.push('/settings')` +- [X] T012 [P] [US1] Add gear `IconButton` to `apps/cirquiz/app/setup.tsx` (header area, alongside existing controls) that calls `router.push('/settings')` + +**Checkpoint**: User Story 1 complete — verify: settings opens from home and setup, source switches to The Trivia API, new game fetches TTA questions, back navigation returns to the originating screen. + +--- + +## Phase 4: User Story 2 — Source Preference Persists (Priority: P2) + +**Goal**: The selected question source survives app close and reopen; defaults to Open Trivia Database on first install. + +**Independent Test**: Select The Trivia API in settings, fully close the app, reopen it, and verify the settings screen still shows The Trivia API as the active source without any user action. + +### Implementation for User Story 2 + +- [X] T013 [US2] Update `apps/cirquiz/src/state/settingsStore.ts` — wrap store with Zustand `persist` middleware using `createJSONStorage(() => AsyncStorage)` at key `@cirquiz/settings`; add `isHydrated: boolean` to state; exclude `isHydrated` from persisted partials (same pattern as `gameStore.ts`) +- [X] T014 [US2] Write `apps/cirquiz/src/state/__tests__/settingsStore.test.ts` — tests for default value (`'otdb'`), `setQuestionSource` updates state, `isHydrated` is `false` before rehydration and `true` after, and persisted value is restored on re-init (mock AsyncStorage) + +**Checkpoint**: User Story 2 complete — verify: select TTA, close app, reopen, settings shows TTA; fresh install / cleared storage defaults to OTDB. + +--- + +## Phase 5: User Story 3 — Setup Adapts to Selected Source (Priority: P3) + +**Goal**: The category list in game setup reflects the active provider's categories; switching source while setup is open clears the selected category and shows a brief toast notice. + +**Independent Test**: Select The Trivia API, navigate to game setup and open the category selector — verify only The Trivia API's 10 categories are shown. Switch back to OTDB and verify the list updates to OTDB's categories. + +### Implementation for User Story 3 + +- [X] T015 [US3] Update `apps/cirquiz/src/hooks/useCategoryLoader.ts` — replace hardcoded `new OpenTriviaDbProvider()` with `getProvider(useSettingsStore.getState().questionSource)` so categories load from the active provider; re-fetch when `questionSource` changes +- [X] T016 [US3] Update `apps/cirquiz/app/setup.tsx` — observe `questionSource` from `useSettingsStore` in a `useEffect`; when source changes and a category is currently selected, clear it and display a brief non-blocking toast notice + +**Checkpoint**: User Story 3 complete — verify: TTA active → setup shows 10 TTA categories; OTDB active → setup shows OTDB categories; changing source while setup is open clears selected category and shows toast. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T017 Run quality gates from monorepo root: `yarn workspace cirquiz lint && yarn workspace cirquiz format:check && yarn workspace cirquiz typecheck` — resolve all errors +- [X] T018 Validate all US1 acceptance scenarios from `specs/002-provider-settings/quickstart.md` — confirm navigation flow, source switching, and full-game completion with both providers + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No tasks — proceed to Phase 2 immediately +- **Foundational (Phase 2)**: No prior dependencies — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Phase 2 completion (T001–T005) +- **US2 (Phase 4)**: Depends on US1 (T007 must exist before T013 extends it) +- **US3 (Phase 5)**: Depends on Phase 2 (T004 for `getProvider`) and US1 (T007 for `useSettingsStore`) +- **Polish (Phase 6)**: Depends on all desired stories being complete + +### User Story Dependencies + +- **US1 (P1)**: Unblocked after Phase 2 completes +- **US2 (P2)**: Extends `settingsStore.ts` created in T007 — must follow US1 +- **US3 (P3)**: Requires `getProvider` (T004) and `useSettingsStore` (T007) — can start after Phase 2 and T007 + +### Within Each Phase + +- T001 → T002 (types before implementation) +- T002 → T003 (or parallel — test can be written against the interface) +- T002 → T004 (provider before factory) +- T006 → T010 (i18n keys before settings screen UI) +- T007 → T008 (store before gameStore integration) +- T008 → T009 (gameStore implementation before its test) +- T007 → T010 (store before settings screen) +- T011 and T012 are parallel (different files) +- T007 → T013 (in-memory store before adding persistence) +- T015 → T016 (category loader before setup observes source changes) + +--- + +## Parallel Example: User Story 1 + +```bash +# After T006 and T007 complete, these can run in parallel: +T011: "Add gear IconButton to apps/cirquiz/app/index.tsx" +T012: "Add gear IconButton to apps/cirquiz/app/setup.tsx" + +# T006 can run in parallel with T007 (different files): +T006: "Add settings.* keys to apps/cirquiz/src/i18n/en.json" +T007: "Create apps/cirquiz/src/state/settingsStore.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 2: Foundational (`TheTriviaApiProvider` + factory) +2. Complete Phase 3: User Story 1 (settings screen + navigation + `gameStore` integration) +3. **STOP and VALIDATE**: Switch source to The Trivia API, play a full game, verify TTA questions are served +4. Demo / ship if ready + +### Incremental Delivery + +1. **Phase 2** → Provider + factory ready +2. **US1** → Settings screen, source switching, both providers work → **MVP** +3. **US2** → Source preference persists across sessions +4. **US3** → Category list adapts to active provider + toast on change + +--- + +## Notes + +- No new packages needed; all dependencies are already installed in the monorepo +- `setProviderForTesting` export from `providerFactory.ts` keeps test injection clean and separate from production routing +- US2 is a minimal incremental change to the store created in US1 (add `persist` middleware) +- US3 can be developed in parallel with US2 once Phase 2 and T007 are done +- Run quality gates (`lint`, `format:check`, `typecheck`) after each phase checkpoint before committing