diff --git a/.agent/logs/refactor-journal.md b/.agent/logs/refactor-journal.md index 14dd516..06e43d3 100644 --- a/.agent/logs/refactor-journal.md +++ b/.agent/logs/refactor-journal.md @@ -76,3 +76,13 @@ - `src/utils/gamificationState.ts` — updated to export the new logic module. - **🧹 Architectural Gain:** SRP — The state store component now delegates gamification data validation, migration, and update logic to a pure, testable function inside `src/utils/gamification/`. - **🔬 Verification:** `tsc`, `npm test`, `npm run lint`, and `npm run build` all passed successfully. + +## 2025-01-26 — Extract `useWorldMapState` hook from `WorldMap.tsx` + +- **💡 What:** Extracted `useWorldMapState` hook from `src/components/game/WorldMap.tsx`, removing ~180 lines of state management, callbacks, and `useEffect` logic. +- **🎯 Why:** Code smell identified (SRP violation and God component). The `WorldMap.tsx` component was 295 lines long, mixing UI rendering with complex state logic like story progression, tutorial orchestration, saga stats calculation, and persistence (`useLocalStorage`). +- **📁 Files Changed:** + - `src/components/game/WorldMap.tsx`: Cleaned up to be purely a presentation layer. + - `src/hooks/game/useWorldMapState.ts`: Created to house all the extracted state and orchestration logic. +- **🧹 Architectural Gain:** SRP — `WorldMap.tsx` now owns only the view rendering, while `useWorldMapState` manages domain state and orchestration. This improves testability and readability. +- **🔬 Verification:** Confirmation that `npx tsc -b --noEmit`, `npm run lint`, `npm test`, and `npm run build` all passed. (Resolved a synchronous setState inside an effect along the way). diff --git a/src/components/game/WorldMap.tsx b/src/components/game/WorldMap.tsx index ee3957c..492bda4 100644 --- a/src/components/game/WorldMap.tsx +++ b/src/components/game/WorldMap.tsx @@ -1,191 +1,42 @@ -import { useState, useCallback, useMemo, useEffect, memo } from 'react'; +import { memo } from 'react'; import type { World } from '../../types/question'; -import { useAuth } from '../../hooks/useAuth'; -import { useGamification } from '../../context/GamificationContext'; import { TutorialModal, FlashcardDeck } from '../education'; import { getTutorialByWorld } from '../../data/educationContent'; import { WORLDS, SAGAS } from '../../data/worlds'; -import type { WorldInfo } from '../../data/worlds'; import { StoryModal } from './StoryModal'; import { WorldCard } from './WorldCard'; import { SagaTabs } from './SagaTabs'; import { SagaBanner } from './SagaBanner'; -import { STORY_CHAPTERS, type StoryEpisode } from '../../data/gamificationData'; -import { calculateSagaStats } from '../../utils/game/sagaLogic'; -import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { useWorldMapState } from '../../hooks/game/useWorldMapState'; import './WorldMap.css'; -const VIEWED_TUTORIALS_KEY = 'pyexplorer_viewed_tutorials'; -const VIEWED_STORIES_KEY = 'pyexplorer_viewed_stories'; - interface WorldMapProps { onSelectWorld: (world: World) => void; worldProgress?: Map; } export const WorldMap = memo(function WorldMap({ onSelectWorld, worldProgress }: WorldMapProps) { - const { userData } = useAuth(); - const { gamification } = useGamification(); - const userScore = userData?.totalScore || 0; - const effectiveScore = Math.max(userScore, gamification?.level?.totalXP || 0); - const unlockedWorlds = useMemo(() => userData?.unlockedWorlds || ['basic_commands'], [userData?.unlockedWorlds]); - - // Aba de Saga selecionada ('all' | 'saga_1' | 'saga_2' | 'saga_3' | 'saga_4') - const [selectedSagaId, setSelectedSagaId] = useState('all'); - - // Estados para modais e tutoriais - const [showTutorial, setShowTutorial] = useState(null); - const [showFlashcards, setShowFlashcards] = useState(null); - const [selectedWorld, setSelectedWorld] = useState(null); - const [activeStory, setActiveStory] = useState(null); - const [pendingWorldNavigation, setPendingWorldNavigation] = useState(null); - - const [viewedTutorials, setViewedTutorials] = useLocalStorage(VIEWED_TUTORIALS_KEY, []); - const [viewedStories, setViewedStories] = useLocalStorage(VIEWED_STORIES_KEY, []); - - const isWorldUnlocked = useCallback((world: WorldInfo): boolean => { - if (!world.requiredScore) return true; - if (unlockedWorlds.includes(world.id)) return true; - - const progress = worldProgress?.get(world.id); - if (progress && progress.completed > 0) return true; - - return effectiveScore >= world.requiredScore; - }, [unlockedWorlds, effectiveScore, worldProgress]); - - const getWorldStatus = useCallback((world: WorldInfo) => { - const progress = worldProgress?.get(world.id); - if (!progress) return { completed: 0, total: 0, percentage: 0 }; - - return { - ...progress, - percentage: progress.total > 0 ? (progress.completed / progress.total) * 100 : 0, - }; - }, [worldProgress]); - - // Cálculo das estatísticas por Saga - const sagaStats = useMemo(() => { - return calculateSagaStats(SAGAS, WORLDS, worldProgress, isWorldUnlocked); - }, [worldProgress, isWorldUnlocked]); - - // Sagas filtradas para exibição - const displayedSagas = useMemo(() => { - if (selectedSagaId === 'all') return SAGAS; - return SAGAS.filter(s => s.id === selectedSagaId); - }, [selectedSagaId]); - - const hasViewedTutorial = useCallback((worldId: World): boolean => { - return viewedTutorials.includes(worldId); - }, [viewedTutorials]); - - const markTutorialViewed = useCallback((worldId: World) => { - setViewedTutorials(prev => { - if (prev.includes(worldId)) return prev; - return [...prev, worldId]; - }); - }, [setViewedTutorials]); - - const hasViewedStory = useCallback((worldId: string, type: 'intro' | 'outro'): boolean => { - return viewedStories.includes(`${worldId}_${type}`); - }, [viewedStories]); - - const markStoryViewed = useCallback((worldId: string, type: 'intro' | 'outro') => { - const key = `${worldId}_${type}`; - setViewedStories(prev => { - if (prev.includes(key)) return prev; - return [...prev, key]; - }); - }, [setViewedStories]); - - const handleStoryComplete = useCallback(() => { - if (!activeStory) return; - - markStoryViewed(activeStory.worldId, activeStory.type); - setActiveStory(null); - - if (activeStory.type === 'intro' && pendingWorldNavigation) { - const worldId = pendingWorldNavigation; - setPendingWorldNavigation(null); - - const hasTutorial = getTutorialByWorld(worldId); - const alreadyViewedTutorial = hasViewedTutorial(worldId); - - if (hasTutorial && !alreadyViewedTutorial) { - setSelectedWorld(worldId); - setShowTutorial(worldId); - } else { - onSelectWorld(worldId); - } - } - }, [activeStory, markStoryViewed, pendingWorldNavigation, hasViewedTutorial, onSelectWorld]); - - const handleWorldClick = useCallback((world: WorldInfo) => { - if (!isWorldUnlocked(world)) return; - - const introStory = STORY_CHAPTERS.find(s => s.worldId === world.id && s.type === 'intro'); - const viewedIntro = hasViewedStory(world.id, 'intro'); - - if (introStory && !viewedIntro) { - setPendingWorldNavigation(world.id); - setActiveStory(introStory); - return; - } - - const hasTutorial = getTutorialByWorld(world.id); - const alreadyViewed = hasViewedTutorial(world.id); - - if (hasTutorial && !alreadyViewed) { - setSelectedWorld(world.id); - setShowTutorial(world.id); - } else { - onSelectWorld(world.id); - } - }, [hasViewedTutorial, onSelectWorld, isWorldUnlocked, hasViewedStory]); - - const handleTutorialComplete = useCallback(() => { - if (showTutorial) { - markTutorialViewed(showTutorial); - } - if (selectedWorld) { - onSelectWorld(selectedWorld); - } - setShowTutorial(null); - setSelectedWorld(null); - }, [showTutorial, selectedWorld, markTutorialViewed, onSelectWorld]); - - const handleTutorialClose = useCallback(() => { - if (showTutorial) { - markTutorialViewed(showTutorial); - } - setShowTutorial(null); - setSelectedWorld(null); - }, [showTutorial, markTutorialViewed]); - - const handleShowTutorial = useCallback((worldId: World) => { - setShowTutorial(worldId); - }, []); - - const handleShowFlashcards = useCallback((worldId: World) => { - setShowFlashcards(worldId); - }, []); - - // Exibe a história de encerramento quando o mundo é completado 100% - useEffect(() => { - if (!worldProgress) return; - - for (const world of WORLDS) { - const status = worldProgress.get(world.id); - if (status && status.completed === status.total && status.total > 0) { - const outroStory = STORY_CHAPTERS.find(s => s.worldId === world.id && s.type === 'outro'); - if (outroStory && !hasViewedStory(world.id, 'outro')) { - if (!showTutorial && !activeStory) { - setActiveStory(outroStory); - break; - } - } - } - } - }, [worldProgress, hasViewedStory, showTutorial, activeStory]); + const { + effectiveScore, + selectedSagaId, + setSelectedSagaId, + showTutorial, + showFlashcards, + selectedWorld, + activeStory, + isWorldUnlocked, + getWorldStatus, + sagaStats, + displayedSagas, + hasViewedTutorial, + handleStoryComplete, + handleWorldClick, + handleTutorialComplete, + handleTutorialClose, + handleShowTutorial, + handleShowFlashcards, + setShowFlashcards + } = useWorldMapState(onSelectWorld, worldProgress); return (
diff --git a/src/hooks/game/useWorldMapState.ts b/src/hooks/game/useWorldMapState.ts new file mode 100644 index 0000000..e4b660d --- /dev/null +++ b/src/hooks/game/useWorldMapState.ts @@ -0,0 +1,211 @@ +import { useState, useCallback, useMemo, useEffect } from 'react'; +import type { World } from '../../types/question'; +import { useAuth } from '../../hooks/useAuth'; +import { useGamification } from '../../context/GamificationContext'; +import { getTutorialByWorld } from '../../data/educationContent'; +import { WORLDS, SAGAS } from '../../data/worlds'; +import type { WorldInfo } from '../../data/worlds'; +import { STORY_CHAPTERS, type StoryEpisode } from '../../data/gamificationData'; +import { calculateSagaStats } from '../../utils/game/sagaLogic'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; + +const VIEWED_TUTORIALS_KEY = 'pyexplorer_viewed_tutorials'; +const VIEWED_STORIES_KEY = 'pyexplorer_viewed_stories'; + +export function useWorldMapState( + onSelectWorld: (world: World) => void, + worldProgress?: Map +) { + const { userData } = useAuth(); + const { gamification } = useGamification(); + const userScore = userData?.totalScore || 0; + const effectiveScore = Math.max(userScore, gamification?.level?.totalXP || 0); + const unlockedWorlds = useMemo(() => userData?.unlockedWorlds || ['basic_commands'], [userData?.unlockedWorlds]); + + // Aba de Saga selecionada ('all' | 'saga_1' | 'saga_2' | 'saga_3' | 'saga_4') + const [selectedSagaId, setSelectedSagaId] = useState('all'); + + // Estados para modais e tutoriais + const [showTutorial, setShowTutorial] = useState(null); + const [showFlashcards, setShowFlashcards] = useState(null); + const [selectedWorld, setSelectedWorld] = useState(null); + const [activeStory, setActiveStory] = useState(null); + const [pendingWorldNavigation, setPendingWorldNavigation] = useState(null); + + const [viewedTutorials, setViewedTutorials] = useLocalStorage(VIEWED_TUTORIALS_KEY, []); + const [viewedStories, setViewedStories] = useLocalStorage(VIEWED_STORIES_KEY, []); + + const isWorldUnlocked = useCallback((world: WorldInfo): boolean => { + if (!world.requiredScore) return true; + if (unlockedWorlds.includes(world.id)) return true; + + const progress = worldProgress?.get(world.id); + if (progress && progress.completed > 0) return true; + + return effectiveScore >= world.requiredScore; + }, [unlockedWorlds, effectiveScore, worldProgress]); + + const getWorldStatus = useCallback((world: WorldInfo) => { + const progress = worldProgress?.get(world.id); + if (!progress) return { completed: 0, total: 0, percentage: 0 }; + + return { + ...progress, + percentage: progress.total > 0 ? (progress.completed / progress.total) * 100 : 0, + }; + }, [worldProgress]); + + // Cálculo das estatísticas por Saga + const sagaStats = useMemo(() => { + return calculateSagaStats(SAGAS, WORLDS, worldProgress, isWorldUnlocked); + }, [worldProgress, isWorldUnlocked]); + + // Sagas filtradas para exibição + const displayedSagas = useMemo(() => { + if (selectedSagaId === 'all') return SAGAS; + return SAGAS.filter(s => s.id === selectedSagaId); + }, [selectedSagaId]); + + const hasViewedTutorial = useCallback((worldId: World): boolean => { + return viewedTutorials.includes(worldId); + }, [viewedTutorials]); + + const markTutorialViewed = useCallback((worldId: World) => { + setViewedTutorials(prev => { + if (prev.includes(worldId)) return prev; + return [...prev, worldId]; + }); + }, [setViewedTutorials]); + + const hasViewedStory = useCallback((worldId: string, type: 'intro' | 'outro'): boolean => { + return viewedStories.includes(`${worldId}_${type}`); + }, [viewedStories]); + + const markStoryViewed = useCallback((worldId: string, type: 'intro' | 'outro') => { + const key = `${worldId}_${type}`; + setViewedStories(prev => { + if (prev.includes(key)) return prev; + return [...prev, key]; + }); + }, [setViewedStories]); + + const handleStoryComplete = useCallback(() => { + if (!activeStory) return; + + markStoryViewed(activeStory.worldId, activeStory.type); + setActiveStory(null); + + if (activeStory.type === 'intro' && pendingWorldNavigation) { + const worldId = pendingWorldNavigation; + setPendingWorldNavigation(null); + + const hasTutorial = getTutorialByWorld(worldId); + const alreadyViewedTutorial = hasViewedTutorial(worldId); + + if (hasTutorial && !alreadyViewedTutorial) { + setSelectedWorld(worldId); + setShowTutorial(worldId); + } else { + onSelectWorld(worldId); + } + } + }, [activeStory, markStoryViewed, pendingWorldNavigation, hasViewedTutorial, onSelectWorld]); + + const handleWorldClick = useCallback((world: WorldInfo) => { + if (!isWorldUnlocked(world)) return; + + const introStory = STORY_CHAPTERS.find(s => s.worldId === world.id && s.type === 'intro'); + const viewedIntro = hasViewedStory(world.id, 'intro'); + + if (introStory && !viewedIntro) { + setPendingWorldNavigation(world.id); + setActiveStory(introStory); + return; + } + + const hasTutorial = getTutorialByWorld(world.id); + const alreadyViewed = hasViewedTutorial(world.id); + + if (hasTutorial && !alreadyViewed) { + setSelectedWorld(world.id); + setShowTutorial(world.id); + } else { + onSelectWorld(world.id); + } + }, [hasViewedTutorial, onSelectWorld, isWorldUnlocked, hasViewedStory]); + + const handleTutorialComplete = useCallback(() => { + if (showTutorial) { + markTutorialViewed(showTutorial); + } + if (selectedWorld) { + onSelectWorld(selectedWorld); + } + setShowTutorial(null); + setSelectedWorld(null); + }, [showTutorial, selectedWorld, markTutorialViewed, onSelectWorld]); + + const handleTutorialClose = useCallback(() => { + if (showTutorial) { + markTutorialViewed(showTutorial); + } + setShowTutorial(null); + setSelectedWorld(null); + }, [showTutorial, markTutorialViewed]); + + const handleShowTutorial = useCallback((worldId: World) => { + setShowTutorial(worldId); + }, []); + + const handleShowFlashcards = useCallback((worldId: World) => { + setShowFlashcards(worldId); + }, []); + + // Exibe a história de encerramento quando o mundo é completado 100% + useEffect(() => { + if (!worldProgress) return; + + let pendingOutroStory: StoryEpisode | undefined; + + for (const world of WORLDS) { + const status = worldProgress.get(world.id); + if (status && status.completed === status.total && status.total > 0) { + const outroStory = STORY_CHAPTERS.find(s => s.worldId === world.id && s.type === 'outro'); + if (outroStory && !hasViewedStory(world.id, 'outro')) { + if (!showTutorial && !activeStory) { + pendingOutroStory = outroStory; + break; + } + } + } + } + + if (pendingOutroStory) { + setTimeout(() => { + setActiveStory(pendingOutroStory!); + }, 0); + } + }, [worldProgress, hasViewedStory, showTutorial, activeStory]); + + return { + effectiveScore, + selectedSagaId, + setSelectedSagaId, + showTutorial, + showFlashcards, + selectedWorld, + activeStory, + isWorldUnlocked, + getWorldStatus, + sagaStats, + displayedSagas, + hasViewedTutorial, + handleStoryComplete, + handleWorldClick, + handleTutorialComplete, + handleTutorialClose, + handleShowTutorial, + handleShowFlashcards, + setShowFlashcards + }; +}