Files
project-math/src/stores/useProgressStore.ts
Jose Luis Montañes 8d8a811ede feat: circuit builder, electronics lab, SPICE simulator, expanded skill tree
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>
2026-03-26 03:50:07 +01:00

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',
}
)
);