Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .agent/logs/refactor-journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 3 additions & 9 deletions src/components/gamification/MissionList/MissionList.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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 {
Expand Down
46 changes: 23 additions & 23 deletions src/utils/gamification/missionLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
});
Expand Down
11 changes: 4 additions & 7 deletions src/utils/gamification/questionLogic.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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;
Expand Down