Skip to content
Merged
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 @@ -24,3 +24,14 @@
- `src/hooks/gamification/useGamificationStore.ts`: Replaced inline logic with the pure function call and handled side-effects cleanly.
- **🧹 Architectural Gain:** SRP — `useGamificationStore` now focuses solely on state management and side effects, while domain rules for post-load logic reside in purely testable utility functions.
- **🔬 Verification:** Confirmed that `tsc -b --noEmit`, `npm test`, `npm run lint`, and `npm run build` all pass successfully.

## 2025-02-14 — Centralize Endgame Mission Lookup

- **💡 What:** Extracted duplicated logic for finding and mapping endgame missions by ID into a pure `getEndgameMissionDef` function. Replaced inline string-splitting parsing inside `missionLogic.ts`, `questionLogic.ts` and `MissionList.tsx`.
- **🎯 Why:** Code smell identified (DRY Violation, brittle string splitting). The logic for resolving endgame missions (e.g. `endgame_speedrun_0` to the base `Mission` definition) was duplicated across three different files, with inconsistent fallback mechanisms.
- **📁 Files Changed:**
- `src/utils/gamification/missionLogic.ts`: Created `getEndgameMissionDef` and refactored multiple spots to use it.
- `src/utils/gamification/questionLogic.ts`: Replaced find logic with `getEndgameMissionDef`.
- `src/components/gamification/MissionList/MissionList.tsx`: Simplified UI mapping.
- **🧹 Architectural Gain:** DRY principle — centralized the ID parsing rules into a single source of truth that safely merges dynamic user mission IDs with static generic definitions.
- **🔬 Verification:** Confirmed that `npx tsc -b --noEmit`, `npm test`, `npm run lint`, and `CI=true npm run build` all pass successfully.
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,7 @@
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 @@ -21,16 +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
44 changes: 29 additions & 15 deletions src/utils/gamification/missionLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,8 @@ export function claimMissionRewardLogic(
// 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);

// 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 @@ -242,7 +240,7 @@ export function updateMissionProgress(
let missionDef = allMissions.find(m => 'id' in 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 @@ -371,6 +369,29 @@ function processHydration(mission: Mission, originalId: string): Mission {
return mission;
}

export function getEndgameMissionDef(missionId: string): Mission | undefined {
if (!missionId.startsWith('endgame_')) return undefined;

let found = ENDGAME_MISSIONS.find(m => missionId.startsWith(`endgame_${m.objectiveType}`));

// Fallback for legacy ID formats (e.g. endgame_improve_0)
if (!found) {
const typePart = missionId.split('_')[1];
if (typePart) {
found = ENDGAME_MISSIONS.find(m =>
m.objectiveType === typePart ||
(typePart === 'improve' && m.objectiveType === 'improve_stars') ||
(typePart === 'syntax' && m.objectiveType === 'syntax_master')
);
}
}

if (found) {
return { ...found, id: missionId } as Mission;
}
return undefined;
}

export function hydrateMissions(activeMissions: UserGamification['activeMissions']): { daily: Mission[], weekly: Mission[] } {
const daily: Mission[] = [];
const weekly: Mission[] = [];
Expand Down Expand Up @@ -399,17 +420,10 @@ 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}`));
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.
const foundDef = getEndgameMissionDef(baseId);
if (foundDef) {
const hydrated = { ...foundDef, 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
Loading