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.

## $(date +%Y-%m-%d) — Extract useWorldMapState from WorldMap

- **💡 What:** Extracted complex state and business logic (like handling tutorials, stories, and saga statistics) from `src/components/game/WorldMap.tsx` into a new custom hook `src/hooks/game/useWorldMapState.ts`.
- **🎯 Why:** `WorldMap.tsx` was bloated (~300 lines) and violated the Single Responsibility Principle by mixing UI rendering logic with complex game state orchestrations.
- **📁 Files Changed:**
- `src/components/game/WorldMap.tsx` - Removed state hooks and functions.
- `src/hooks/game/useWorldMapState.ts` - Created to encapsulate WorldMap's local logic.
- **🧹 Architectural Gain:** SRP — `WorldMap.tsx` now focuses solely on rendering the map view, while `useWorldMapState.ts` manages the domain logic for map interactions.
- **🔬 Verification:** Confirmed that `npx tsc -b --noEmit`, `npm test`, `npm run lint`, and `npm run build` all pass. Fixed a linting issue where `setActiveStory` was called directly inside a `useEffect` loop.
189 changes: 25 additions & 164 deletions src/components/game/WorldMap.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
import { useState, useCallback, useMemo, useEffect, memo } from 'react';
import { memo, useMemo } 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 }>;
Expand All @@ -30,162 +24,29 @@ export const WorldMap = memo(function WorldMap({ onSelectWorld, worldProgress }:
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 {
selectedSagaId, setSelectedSagaId,
showTutorial,
showFlashcards, setShowFlashcards,
selectedWorld,
activeStory,
isWorldUnlocked,
getWorldStatus,
sagaStats,
displayedSagas,
hasViewedTutorial,
handleStoryComplete,
handleWorldClick,
handleTutorialComplete,
handleTutorialClose,
handleShowTutorial,
handleShowFlashcards
} = useWorldMapState({
onSelectWorld,
worldProgress,
effectiveScore,
unlockedWorlds
});

return (
<div className="world-map">
Expand Down
Loading
Loading