Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .agent/logs/refactor-journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
195 changes: 23 additions & 172 deletions src/components/game/WorldMap.tsx
Original file line number Diff line number Diff line change
@@ -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<World, { completed: number; total: number }>;
}

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<string>('all');

// Estados para modais e tutoriais
const [showTutorial, setShowTutorial] = useState<World | null>(null);
const [showFlashcards, setShowFlashcards] = useState<World | null>(null);
const [selectedWorld, setSelectedWorld] = useState<World | null>(null);
const [activeStory, setActiveStory] = useState<StoryEpisode | null>(null);
const [pendingWorldNavigation, setPendingWorldNavigation] = useState<World | null>(null);

const [viewedTutorials, setViewedTutorials] = useLocalStorage<World[]>(VIEWED_TUTORIALS_KEY, []);
const [viewedStories, setViewedStories] = useLocalStorage<string[]>(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 (
<div className="world-map">
Expand Down
Loading
Loading