diff --git a/.agent/logs/refactor-journal.md b/.agent/logs/refactor-journal.md index 5de6053..e6a3959 100644 --- a/.agent/logs/refactor-journal.md +++ b/.agent/logs/refactor-journal.md @@ -15,3 +15,14 @@ - `src/utils/pyodide/runner.ts`: Split testing logic into dedicated private helper functions. - **๐Ÿงน Architectural Gain:** SRP โ€” `runPythonCode` now orchestrates execution and simply delegates validation to focused helper functions, increasing readability and testability. - **๐Ÿ”ฌ Verification:** Confirmed that `npx tsc --noEmit` and `npm test` passed successfully. + +## 2024-05-18 โ€” [Extract `getEndgameMissionDef` logic] + +- **๐Ÿ’ก What:** Extracted duplicated logic for finding and hydrating `ENDGAME_MISSIONS` by objective type prefix into a single pure utility function `getEndgameMissionDef`. +- **๐ŸŽฏ Why:** Code smell identified (DRY violation): the same string parsing `.split('_')[1]`, `.startsWith()`, and hydration logic for endgame missions was duplicated across `missionLogic.ts`, `questionLogic.ts`, and `MissionList.tsx`. +- **๐Ÿ“ Files Changed:** + - `src/utils/gamification/missionLogic.ts`: Added helper and used it in 3 places. + - `src/utils/gamification/questionLogic.ts`: Replaced duplicated lookup with helper. + - `src/components/gamification/MissionList/MissionList.tsx`: Replaced duplicated lookup with helper. +- **๐Ÿงน Architectural Gain:** DRY principle applied. Centralizing this logic makes modifying endgame mission ID formats or hydration rules much safer, as it only needs to be updated in one place. +- **๐Ÿ”ฌ Verification:** Confirmed that `tsc`, `npm test`, `npm run lint`, and `npm run build` all passed. diff --git a/src/components/gamification/MissionList/MissionList.tsx b/src/components/gamification/MissionList/MissionList.tsx index 9ce979c..8b64227 100644 --- a/src/components/gamification/MissionList/MissionList.tsx +++ b/src/components/gamification/MissionList/MissionList.tsx @@ -1,7 +1,6 @@ import type { Mission, UserMission } from '../../../types/gamification'; import './MissionList.css'; - -import { ENDGAME_MISSIONS } from '../../../data/gamificationData'; +import { getEndgameMissionDef } from '../../../utils/gamification/missionLogic'; interface MissionListProps { dailyMissions: Mission[]; @@ -22,15 +21,10 @@ export function MissionList({ dailyMissions, weeklyMissions, userMissions }: Mis // Construct Mission objects for them const activeEndgameMissions: Mission[] = endgameUserMissions.map(um => { // Try to find matching definition - // ID format: endgame_{objectiveType}_{index} - const typePart = um.missionId.split('_')[1]; - const def = ENDGAME_MISSIONS.find(m => m.objectiveType === typePart || (typePart === 'improve' && m.objectiveType === 'improve_stars') || (typePart === 'syntax' && m.objectiveType === 'syntax_master')); + const def = getEndgameMissionDef(um.missionId); if (def) { - return { - ...def, - id: um.missionId - }; + return def; } // Fallback if not found (should not happen if data is consistent) return { diff --git a/src/utils/gamification/missionLogic.ts b/src/utils/gamification/missionLogic.ts index 80d52a7..8ac5967 100644 --- a/src/utils/gamification/missionLogic.ts +++ b/src/utils/gamification/missionLogic.ts @@ -6,6 +6,19 @@ import type { import { getLevelFromXP, ENDGAME_MISSIONS, generateDailyMissions, generateWeeklyMissions } from '../../data/gamificationData'; import { getLocalDateStr } from '../gamificationUtils'; +/** + * Helper to find and hydrate an endgame mission definition based on its dynamic ID. + * Pure function. + */ +export function getEndgameMissionDef(missionId: string): Mission | undefined { + const typePart = missionId.replace(/^endgame_/, '').replace(/_\d+(_review)?$/, ''); + const found = ENDGAME_MISSIONS.find(m => m.objectiveType === typePart || missionId.startsWith(`endgame_${m.objectiveType}`)); + if (found) { + return { ...found, id: missionId } as Mission; + } + return undefined; +} + export function checkDailyAndWeeklyReset(data: UserGamification): UserGamification | null { const today = new Date(); const todayStr = getLocalDateStr(today); @@ -169,16 +182,11 @@ export function claimMissionRewardLogic( const daily = generateDailyMissions(today); const weekly = generateWeeklyMissions(today); - const allMissions = [...daily, ...weekly, ...ENDGAME_MISSIONS]; - // For endgame missions, the ID might be constructed (e.g. endgame_speedrun_0) - // We need to find the definition either by exact match or by prefix for static endgame definitions - // Note: ENDGAME_MISSIONS do not have 'id' property in definitions, so we cast to any or handle specifically - let missionDef = allMissions.find(m => 'id' in m && m.id === missionId); + const allMissions = [...daily, ...weekly]; + let missionDef = allMissions.find(m => m.id === missionId); - // If not found, try to find in ENDGAME_MISSIONS by title matching (since we construct IDs dynamically) if (!missionDef) { - // Find endgame mission definition by checking if the missionId (from user state) matches the constructed pattern - missionDef = ENDGAME_MISSIONS.find(m => missionId.startsWith(`endgame_${m.objectiveType}`)) as Mission; + missionDef = getEndgameMissionDef(missionId); } if (!missionDef) { @@ -232,17 +240,15 @@ export function updateMissionProgress( const today = new Date(); const daily = generateDailyMissions(today); const weekly = generateWeeklyMissions(today); - // Include Endgame missions for lookup - const allMissions = [...daily, ...weekly, ...ENDGAME_MISSIONS]; + const allMissions = [...daily, ...weekly]; const newActiveMissions = state.activeMissions.map(userMission => { if (userMission.status !== 'active') return userMission; - // Smart Lookup (duplicate logic from claimMissionRewardLogic) - let missionDef = allMissions.find(m => 'id' in m && m.id === userMission.missionId); + let missionDef = allMissions.find(m => m.id === userMission.missionId); if (!missionDef) { - missionDef = ENDGAME_MISSIONS.find(m => userMission.missionId.startsWith(`endgame_${m.objectiveType}`)) as Mission; + missionDef = getEndgameMissionDef(userMission.missionId); } if (!missionDef) return userMission; @@ -399,17 +405,11 @@ export function hydrateMissions(activeMissions: UserGamification['activeMissions if (found) weekly.push(isReview ? processHydration(found, um.missionId) : found); } } else if (baseId.startsWith('endgame_')) { - // Handle endgame hydration if needed (already static basically) - // But we need to match the specific endgame mission - // const typePart = baseId.split('_')[1]; - const found = ENDGAME_MISSIONS.find(m => baseId.startsWith(`endgame_${m.objectiveType}`)); + const found = getEndgameMissionDef(baseId); if (found) { - const hydrated = { ...found, id: um.missionId } as Mission; // Cast needed as ENDGAME lacks ID - daily.push(hydrated); // Endgame usually shown in daily or separate list? The UI splits daily/weekly. - // Ideally endgame is its own category. But for now put in Daily or Weekly based on... choice. - // Actually the UI currently only shows Daily/Weekly props. - // We might need to add 'endgame' prop to UI later. - // For now, let's just push to daily to ensure visibility. + // Ensure the final ID matches exactly the one in the user state (e.g. including _review) + const hydrated = { ...found, id: um.missionId }; + daily.push(isReview ? processHydration(hydrated, um.missionId) : hydrated); } } }); diff --git a/src/utils/gamification/questionLogic.ts b/src/utils/gamification/questionLogic.ts index 319933d..5518f6d 100644 --- a/src/utils/gamification/questionLogic.ts +++ b/src/utils/gamification/questionLogic.ts @@ -1,6 +1,7 @@ -import type { UserGamification, LevelInfo, Mission } from '../../types/gamification'; -import { getLevelFromXP, ENDGAME_MISSIONS, generateDailyMissions, generateWeeklyMissions } from '../../data/gamificationData'; +import type { UserGamification, LevelInfo } from '../../types/gamification'; +import { getLevelFromXP, generateDailyMissions, generateWeeklyMissions } from '../../data/gamificationData'; import { calculateStreak } from '../gamificationUtils'; +import { getEndgameMissionDef } from './missionLogic'; import { getInitialPet, gainPetXp } from '../petLogic'; export function recordQuestionLogic( @@ -114,11 +115,7 @@ export function recordQuestionLogic( let def = [...daily, ...weekly].find(m => m.id === mission.missionId); if (!def && mission.missionId.startsWith('endgame_')) { - const typePart = mission.missionId.split('_')[1]; - const found = ENDGAME_MISSIONS.find(m => m.objectiveType === typePart || (typePart === 'improve' && m.objectiveType === 'improve_stars') || (typePart === 'syntax' && m.objectiveType === 'syntax_master')); - if (found) { - def = { ...found, id: mission.missionId } as Mission; - } + def = getEndgameMissionDef(mission.missionId); } if (!def) return mission;