feat: implement MathTree MVP — skill tree, workbench, and progress system
- Interactive skill tree with dagre auto-layout and React Flow (15 math+logic nodes) - Workbench with math input (LTR/RTL toggle), multiple choice, keyboard shortcuts - Challenge verification engine with retry-based flow (no answer reveal until 3 failures) - Scratchpad canvas with freehand drawing, text boxes, eraser, colors, and stroke sizes - "Aprende primero" collapsible explanations on introductory challenges - XP/level system, daily streaks, 7 achievements, progress persistence via Zustand - Profile page with stats and achievement gallery - Sidebar navigation with XP bar and streak badge Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
src/stores/useProgressStore.ts
Normal file
182
src/stores/useProgressStore.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { UserProgress, levelFromXP } from '@/types/user';
|
||||
import { getAvailableNodes, skillNodes } from '@/data/skill-tree';
|
||||
import { getChallengesForNode } from '@/data/challenges/math';
|
||||
|
||||
interface ProgressStore extends UserProgress {
|
||||
completeChallenge: (challengeId: string, timeSpent: number) => void;
|
||||
getNodeStatus: (nodeId: string) => 'locked' | 'available' | 'in-progress' | 'completed';
|
||||
getCompletedNodeIds: () => string[];
|
||||
updateStreak: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: UserProgress = {
|
||||
completedChallenges: {},
|
||||
unlockedNodes: [],
|
||||
totalXP: 0,
|
||||
level: 1,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
lastActiveDate: '',
|
||||
achievements: [],
|
||||
};
|
||||
|
||||
export const useProgressStore = create<ProgressStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
completeChallenge: (challengeId: string, timeSpent: number) => {
|
||||
const state = get();
|
||||
if (state.completedChallenges[challengeId]) return;
|
||||
|
||||
// Find challenge xp
|
||||
const allChallenges = skillNodes.flatMap((n) =>
|
||||
getChallengesForNode(n.id)
|
||||
);
|
||||
const challenge = allChallenges.find((c) => c.id === challengeId);
|
||||
const xpReward = challenge?.xpReward ?? 20;
|
||||
|
||||
const newCompleted = {
|
||||
...state.completedChallenges,
|
||||
[challengeId]: {
|
||||
bestScore: 100,
|
||||
completedAt: new Date().toISOString(),
|
||||
timeSpent,
|
||||
attempts: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const newXP = state.totalXP + xpReward;
|
||||
|
||||
// Check which nodes are now completed
|
||||
const completedNodeIds: string[] = [];
|
||||
for (const node of skillNodes) {
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => newCompleted[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) completedNodeIds.push(node.id);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let streak = state.currentStreak;
|
||||
let longest = state.longestStreak;
|
||||
|
||||
if (state.lastActiveDate !== today) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
if (state.lastActiveDate === yesterdayStr) {
|
||||
streak += 1;
|
||||
} else if (state.lastActiveDate !== today) {
|
||||
streak = 1;
|
||||
}
|
||||
longest = Math.max(longest, streak);
|
||||
}
|
||||
|
||||
// Check achievements
|
||||
const newAchievements = [...state.achievements];
|
||||
const completedCount = Object.keys(newCompleted).length;
|
||||
if (completedCount >= 1 && !newAchievements.includes('first-step')) {
|
||||
newAchievements.push('first-step');
|
||||
}
|
||||
if (completedCount >= 10 && !newAchievements.includes('getting-started')) {
|
||||
newAchievements.push('getting-started');
|
||||
}
|
||||
if (completedNodeIds.length >= 3 && !newAchievements.includes('explorer')) {
|
||||
newAchievements.push('explorer');
|
||||
}
|
||||
if (completedNodeIds.length >= 5 && !newAchievements.includes('scholar')) {
|
||||
newAchievements.push('scholar');
|
||||
}
|
||||
if (streak >= 3 && !newAchievements.includes('consistent')) {
|
||||
newAchievements.push('consistent');
|
||||
}
|
||||
if (streak >= 7 && !newAchievements.includes('dedicated')) {
|
||||
newAchievements.push('dedicated');
|
||||
}
|
||||
// Cross-branch: check if completed nodes span multiple branches
|
||||
const branches = new Set(
|
||||
completedNodeIds
|
||||
.map((id) => skillNodes.find((n) => n.id === id)?.branch)
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (branches.size >= 3 && !newAchievements.includes('polymath')) {
|
||||
newAchievements.push('polymath');
|
||||
}
|
||||
|
||||
set({
|
||||
completedChallenges: newCompleted,
|
||||
unlockedNodes: completedNodeIds,
|
||||
totalXP: newXP,
|
||||
level: levelFromXP(newXP),
|
||||
currentStreak: streak,
|
||||
longestStreak: longest,
|
||||
lastActiveDate: today,
|
||||
achievements: newAchievements,
|
||||
});
|
||||
},
|
||||
|
||||
getCompletedNodeIds: () => {
|
||||
const state = get();
|
||||
const completedNodeIds: string[] = [];
|
||||
for (const node of skillNodes) {
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) completedNodeIds.push(node.id);
|
||||
}
|
||||
return completedNodeIds;
|
||||
},
|
||||
|
||||
getNodeStatus: (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = skillNodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'locked';
|
||||
|
||||
const completedNodeIds = get().getCompletedNodeIds();
|
||||
|
||||
const allDone = node.challenges.every(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (allDone) return 'completed';
|
||||
|
||||
const someDone = node.challenges.some(
|
||||
(cId) => state.completedChallenges[`${node.id}/${cId}`]
|
||||
);
|
||||
if (someDone) return 'in-progress';
|
||||
|
||||
const prereqsMet = node.prerequisites.every((p) =>
|
||||
completedNodeIds.includes(p)
|
||||
);
|
||||
if (prereqsMet || node.prerequisites.length === 0) return 'available';
|
||||
|
||||
return 'locked';
|
||||
},
|
||||
|
||||
updateStreak: () => {
|
||||
const state = get();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (state.lastActiveDate === today) return;
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
if (state.lastActiveDate !== yesterdayStr) {
|
||||
set({ currentStreak: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'project-math-progress',
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user