Workbenches: - Circuit Builder: drag-and-drop logic gates, wire connections, truth table verification, fullscreen mode - Electronics Lab: SPICE-like DC simulator with MNA solver, voltage sources, resistors, capacitors, LEDs, switches, NMOS/PMOS transistors, voltmeter, ammeter, play/pause simulation, fullscreen mode - Explanation renderer: auto-detects ASCII truth tables and renders them as styled HTML Skill tree: - 65+ nodes across 19 groups spanning math → electronics → CPU → ASM → OS → networking → web - Groups: Aritmética, Álgebra, Lógica, Electrónica, Circuitos Digitales, Secuenciales, Tu CPU, Verilog/HDL, Arquitectura Extendida, Sistemas Operativos, Programación en C, Redes, La Web, Señales, Síntesis Audio, Gráficos, Tu Consola - Dependency highlighting: clicking a node dims all others and highlights the full path - Group boxes with colored borders around related nodes - Dependency chain audit: fixed illogical prerequisites throughout the tree Content: - 24 electronics challenges (basics, series/parallel, capacitors, diodes, transistors, op-amps, power supplies) - 12 circuit builder challenges (logic gates, NAND universality, combinational circuits) - Fixed all explanation spoilers: examples now use different numbers than the challenge questions - Probe system now requires voltmeter/ammeter instruments instead of checking arbitrary node IDs UX: - Custom dark-themed scrollbars - Fullscreen mode for circuit/electronics editors (portal-based, Esc to exit) - SVG coordinate fix using getScreenCTM for accurate wire placement in fullscreen - Meter reading labels positioned correctly regardless of component rotation - Scratchpad defaults to closed, persists open/close state in localStorage - Empty placeholder nodes show "Próximamente" instead of appearing completed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
'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.length > 0 && 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.length > 0 && 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',
|
|
}
|
|
)
|
|
);
|