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:
Jose Luis Montañes
2026-03-26 01:53:45 +01:00
parent a2846420a3
commit f9f74d3f19
32 changed files with 3031 additions and 67 deletions

16
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@dagrejs/dagre": "^3.0.0",
"@types/katex": "^0.16.8",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
@@ -514,6 +515,21 @@
}
}
},
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.57.2",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.57.2.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@dagrejs/dagre": "^3.0.0",
"@types/katex": "^0.16.8",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",

64
src/app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,64 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { TreePine, Wrench, User, Trophy } from 'lucide-react';
import { XPBar } from '@/components/common/XPBar';
import { StreakBadge } from '@/components/common/StreakBadge';
const navItems = [
{ href: '/skill-tree', label: 'Árbol', icon: TreePine },
{ href: '/profile', label: 'Perfil', icon: User },
];
export default function MainLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-16 md:w-56 shrink-0 border-r border-border bg-card flex flex-col">
<div className="p-3 md:p-4 border-b border-border">
<Link href="/skill-tree" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold text-lg">
M
</div>
<span className="hidden md:block font-bold text-lg">MathTree</span>
</Link>
</div>
<nav className="flex-1 p-2">
{navItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg mb-1 transition-colors ${
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
<span className="hidden md:block text-sm">{item.label}</span>
</Link>
);
})}
</nav>
<div className="p-3 border-t border-border space-y-3">
<div className="hidden md:block">
<XPBar />
</div>
<div className="flex justify-center md:justify-start">
<StreakBadge />
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { useProgressStore } from '@/stores/useProgressStore';
import { xpProgressInLevel } from '@/types/user';
import { skillNodes } from '@/data/skill-tree';
import { allChallenges } from '@/data/challenges/math';
import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Trophy, Flame, Target, Star, BookOpen, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
const achievementMeta: Record<string, { title: string; icon: string; description: string }> = {
'first-step': { title: 'Primer Paso', icon: '🎯', description: 'Completa tu primer reto' },
'getting-started': { title: 'En Marcha', icon: '🚀', description: 'Completa 10 retos' },
'explorer': { title: 'Explorador', icon: '🗺️', description: 'Completa 3 nodos del árbol' },
'scholar': { title: 'Erudito', icon: '📚', description: 'Completa 5 nodos del árbol' },
'consistent': { title: 'Constante', icon: '🔥', description: 'Racha de 3 días' },
'dedicated': { title: 'Dedicado', icon: '💪', description: 'Racha de 7 días' },
'polymath': { title: 'Polímata', icon: '🧠', description: 'Completa nodos de 3 ramas distintas' },
};
export default function ProfilePage() {
const progress = useProgressStore();
const { current, needed } = xpProgressInLevel(progress.totalXP);
const completedChallengeCount = Object.keys(progress.completedChallenges).length;
const completedNodeIds = progress.getCompletedNodeIds();
const totalNodes = skillNodes.length;
const totalChallenges = allChallenges.length;
return (
<div className="h-full overflow-auto">
<header className="px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<h1 className="text-xl font-bold">Tu Perfil</h1>
</header>
<div className="p-4 md:p-8 max-w-4xl mx-auto space-y-6">
{/* Level & XP */}
<Card className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full bg-primary/20 flex items-center justify-center text-2xl font-bold text-primary">
{progress.level}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold">Nivel {progress.level}</h2>
<p className="text-sm text-muted-foreground">{progress.totalXP} XP total</p>
<div className="flex items-center gap-2 mt-2">
<Progress value={(current / needed) * 100} className="flex-1 h-3" />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{current}/{needed} XP
</span>
</div>
</div>
</div>
</Card>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 text-center">
<Target className="w-6 h-6 mx-auto mb-2 text-primary" />
<div className="text-2xl font-bold">{completedChallengeCount}</div>
<div className="text-xs text-muted-foreground">de {totalChallenges} retos</div>
</Card>
<Card className="p-4 text-center">
<BookOpen className="w-6 h-6 mx-auto mb-2 text-green-500" />
<div className="text-2xl font-bold">{completedNodeIds.length}</div>
<div className="text-xs text-muted-foreground">de {totalNodes} nodos</div>
</Card>
<Card className="p-4 text-center">
<Flame className="w-6 h-6 mx-auto mb-2 text-orange-500" />
<div className="text-2xl font-bold">{progress.currentStreak}</div>
<div className="text-xs text-muted-foreground">racha actual</div>
</Card>
<Card className="p-4 text-center">
<Star className="w-6 h-6 mx-auto mb-2 text-amber-500" />
<div className="text-2xl font-bold">{progress.longestStreak}</div>
<div className="text-xs text-muted-foreground">mejor racha</div>
</Card>
</div>
{/* Achievements */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Trophy className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-bold">Logros</h2>
<Badge variant="secondary" className="ml-auto">
{progress.achievements.length}/{Object.keys(achievementMeta).length}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(achievementMeta).map(([id, meta]) => {
const unlocked = progress.achievements.includes(id);
return (
<div
key={id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
unlocked
? 'bg-amber-500/5 border-amber-500/20'
: 'bg-muted/30 border-border opacity-50'
}`}
>
<span className="text-2xl">{meta.icon}</span>
<div>
<p className="text-sm font-medium">{meta.title}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p>
</div>
</div>
);
})}
</div>
</Card>
{/* Reset */}
<div className="text-center">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
if (confirm('¿Estás seguro? Se borrará todo tu progreso.')) {
progress.reset();
}
}}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resetear progreso
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { SkillTreeCanvas } from '@/components/skill-tree/SkillTreeCanvas';
export default function SkillTreePage() {
return (
<div className="h-full flex flex-col">
<header className="px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<h1 className="text-xl font-bold">Árbol de Habilidades</h1>
<p className="text-sm text-muted-foreground">
Haz clic en un nodo para ver sus retos
</p>
</header>
<div className="flex-1">
<SkillTreeCanvas />
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { use } from 'react';
import { notFound } from 'next/navigation';
import { getChallengeById } from '@/data/challenges/math';
import { WorkbenchShell } from '@/components/workbench/WorkbenchShell';
export default function WorkbenchPage({
params,
}: {
params: Promise<{ challengeId: string }>;
}) {
const { challengeId } = use(params);
const challenge = getChallengeById(decodeURIComponent(challengeId));
if (!challenge) {
notFound();
}
return <WorkbenchShell challenge={challenge} />;
}

View File

@@ -128,3 +128,68 @@
@apply font-sans;
}
}
/* React Flow dark mode overrides */
.react-flow__controls {
background: var(--card) !important;
border-color: var(--border) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
border-radius: 8px !important;
overflow: hidden;
}
.react-flow__controls button {
background: var(--card) !important;
border-color: var(--border) !important;
fill: var(--foreground) !important;
color: var(--foreground) !important;
}
.react-flow__controls button:hover {
background: var(--accent) !important;
}
.react-flow__controls button svg {
fill: var(--foreground) !important;
}
.react-flow__edge-path {
stroke-width: 2;
}
.react-flow__attribution {
display: none !important;
}
/* Shake animation for wrong answers */
@keyframes shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-6px); }
30% { transform: translateX(6px); }
45% { transform: translateX(-4px); }
60% { transform: translateX(4px); }
75% { transform: translateX(-2px); }
90% { transform: translateX(2px); }
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
/* Ensure handles are visible and edges connect properly */
.react-flow__handle {
opacity: 0;
}
.react-flow__node:hover .react-flow__handle {
opacity: 1;
}
/* Fix: global border rule was breaking react-flow edge rendering */
.react-flow__edges,
.react-flow__edge,
.react-flow__edge-path,
.react-flow__connection,
.react-flow__handle {
border-color: transparent !important;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MathTree — Aprende desde lo básico hasta lo extraordinario",
description: "Plataforma de aprendizaje interactivo con árbol de habilidades interdisciplinario",
};
export default function RootLayout({
@@ -24,10 +25,12 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="es"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased dark`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<TooltipProvider>{children}</TooltipProvider>
</body>
</html>
);
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from 'next/navigation';
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect('/skill-tree');
}

View File

@@ -0,0 +1,17 @@
'use client';
import { useProgressStore } from '@/stores/useProgressStore';
import { Flame } from 'lucide-react';
export function StreakBadge() {
const { currentStreak } = useProgressStore();
return (
<div className="flex items-center gap-1.5 text-sm">
<Flame className={`w-4 h-4 ${currentStreak > 0 ? 'text-orange-500' : 'text-muted-foreground'}`} />
<span className={currentStreak > 0 ? 'text-orange-500 font-medium' : 'text-muted-foreground'}>
{currentStreak}
</span>
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import { useProgressStore } from '@/stores/useProgressStore';
import { xpProgressInLevel } from '@/types/user';
import { Progress } from '@/components/ui/progress';
export function XPBar() {
const { totalXP, level } = useProgressStore();
const { current, needed } = xpProgressInLevel(totalXP);
const percent = Math.round((current / needed) * 100);
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-sm font-bold text-primary">
{level}
</div>
<div className="text-xs text-muted-foreground">
<div className="font-medium text-foreground">{totalXP} XP</div>
<div>{current}/{needed}</div>
</div>
</div>
<Progress value={percent} className="w-24 h-2" />
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { memo } from 'react';
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import { Lock } from 'lucide-react';
import type { SkillNode, NodeStatus } from '@/types/skill-tree';
type SkillNodeData = SkillNode & { status: NodeStatus } & Record<string, unknown>;
type SkillNodeType = Node<SkillNodeData>;
const statusStyles: Record<NodeStatus, string> = {
locked: 'bg-muted/50 border-muted-foreground/20 opacity-50 cursor-not-allowed',
available: 'bg-card border-primary/50 hover:border-primary hover:shadow-lg hover:shadow-primary/10 cursor-pointer',
'in-progress': 'bg-card border-amber-500/50 shadow-md shadow-amber-500/10 cursor-pointer',
completed: 'bg-card border-green-500/50 shadow-md shadow-green-500/10 cursor-pointer',
mastered: 'bg-card border-purple-500/50 shadow-md shadow-purple-500/10 cursor-pointer',
};
const statusBadge: Record<NodeStatus, { label: string; className: string } | null> = {
locked: null,
available: { label: 'Disponible', className: 'bg-primary/20 text-primary' },
'in-progress': { label: 'En progreso', className: 'bg-amber-500/20 text-amber-500' },
completed: { label: 'Completado', className: 'bg-green-500/20 text-green-500' },
mastered: { label: 'Dominado', className: 'bg-purple-500/20 text-purple-500' },
};
export const SkillNodeComponent = memo(function SkillNodeComponent({
data,
}: NodeProps<SkillNodeType>) {
const status = data.status;
const badge = statusBadge[status];
return (
<div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 min-w-[140px] max-w-[180px] ${statusStyles[status]}`}
>
<Handle type="target" position={Position.Top} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" />
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{status === 'locked' ? '' : data.icon}</span>
{status === 'locked' && <Lock className="w-4 h-4 text-muted-foreground" />}
<span className="text-sm font-semibold truncate">{data.title}</span>
</div>
{badge && (
<div className={`text-[10px] font-medium px-2 py-0.5 rounded-full w-fit ${badge.className}`}>
{badge.label}
</div>
)}
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground/30 !w-2 !h-2 !border-0" />
</div>
);
});

View File

@@ -0,0 +1,135 @@
'use client';
import { useRouter } from 'next/navigation';
import { X, Star, CheckCircle2, Lock, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { getNodeById } from '@/data/skill-tree';
import { getChallengesForNode } from '@/data/challenges/math';
import { useProgressStore } from '@/stores/useProgressStore';
interface SkillNodeDetailProps {
nodeId: string;
onClose: () => void;
}
export function SkillNodeDetail({ nodeId, onClose }: SkillNodeDetailProps) {
const router = useRouter();
const node = getNodeById(nodeId);
const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
const completedChallenges = useProgressStore((s) => s.completedChallenges);
if (!node) return null;
const status = getNodeStatus(nodeId);
const challenges = getChallengesForNode(nodeId);
const completedCount = challenges.filter((c) => completedChallenges[c.id]).length;
return (
<div className="absolute top-0 right-0 h-full w-full sm:w-96 bg-card border-l border-border shadow-2xl z-50 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{node.icon}</span>
<h2 className="text-lg font-bold">{node.title}</h2>
</div>
<p className="text-sm text-muted-foreground">{node.description}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{node.discipline}
</Badge>
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-3 h-3 ${
i < node.difficulty ? 'text-amber-500 fill-amber-500' : 'text-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="shrink-0">
<X className="w-4 h-4" />
</Button>
</div>
{/* Progress */}
<div className="px-4 py-3 bg-muted/30 border-b border-border">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Progreso</span>
<span className="font-medium">
{completedCount}/{challenges.length} retos
</span>
</div>
<div className="w-full h-2 bg-muted rounded-full mt-2">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${challenges.length > 0 ? (completedCount / challenges.length) * 100 : 0}%` }}
/>
</div>
</div>
{/* Challenges list */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{status === 'locked' ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Lock className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm font-medium">Nodo bloqueado</p>
<p className="text-xs mt-1 text-center">
Completa los prerrequisitos para desbloquear
</p>
</div>
) : (
challenges.map((challenge, index) => {
const isCompleted = !!completedChallenges[challenge.id];
return (
<Card
key={challenge.id}
className={`p-3 cursor-pointer transition-all hover:shadow-md ${
isCompleted ? 'bg-green-500/5 border-green-500/20' : 'hover:border-primary/30'
}`}
onClick={() => {
router.push(`/workbench/${encodeURIComponent(challenge.id)}`);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
isCompleted
? 'bg-green-500/20 text-green-500'
: 'bg-muted text-muted-foreground'
}`}
>
{isCompleted ? (
<CheckCircle2 className="w-4 h-4" />
) : (
index + 1
)}
</div>
<div>
<p className="text-sm font-medium">{challenge.title}</p>
<p className="text-xs text-muted-foreground">
+{challenge.xpReward} XP
</p>
</div>
</div>
{!isCompleted && (
<ArrowRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
</Card>
);
})
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useCallback, useMemo, useState, useEffect } from 'react';
import {
ReactFlow,
Background,
Controls,
Node,
Edge,
BackgroundVariant,
Position,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import Dagre from '@dagrejs/dagre';
import { skillNodes, skillEdges } from '@/data/skill-tree';
import { useProgressStore } from '@/stores/useProgressStore';
import { SkillNodeComponent } from './SkillNode';
import { SkillNodeDetail } from './SkillNodeDetail';
const nodeTypes = {
skillNode: SkillNodeComponent,
};
const NODE_WIDTH = 170;
const NODE_HEIGHT = 70;
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'TB', // top to bottom
nodesep: 80, // horizontal spacing between nodes
ranksep: 120, // vertical spacing between ranks
edgesep: 40, // minimum spacing between edges
marginx: 40,
marginy: 40,
});
nodes.forEach((node) => {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
Dagre.layout(g);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = g.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
sourcePosition: Position.Bottom,
targetPosition: Position.Top,
};
});
return { nodes: layoutedNodes, edges };
}
export function SkillTreeCanvas() {
const getNodeStatus = useProgressStore((s) => s.getNodeStatus);
const completedChallenges = useProgressStore((s) => s.completedChallenges);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const { nodes, edges } = useMemo(() => {
const rawNodes: Node[] = skillNodes.map((node) => ({
id: node.id,
type: 'skillNode',
position: { x: 0, y: 0 }, // dagre will override
draggable: false,
data: {
...node,
status: getNodeStatus(node.id),
},
}));
const rawEdges: Edge[] = skillEdges.map((edge) => {
const targetStatus = getNodeStatus(edge.to);
const sourceStatus = getNodeStatus(edge.from);
let strokeColor = '#444';
if (sourceStatus === 'completed' && targetStatus !== 'locked') {
strokeColor = '#22c55e';
} else if (targetStatus === 'available') {
strokeColor = '#6366f1';
} else if (targetStatus !== 'locked') {
strokeColor = '#777';
}
return {
id: `${edge.from}->${edge.to}`,
source: edge.from,
target: edge.to,
type: 'bezier',
animated: targetStatus === 'available',
style: {
stroke: strokeColor,
strokeWidth: 2,
},
};
});
return getLayoutedElements(rawNodes, rawEdges);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getNodeStatus, completedChallenges]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNodeId(node.id);
}, []);
if (!mounted) {
return (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
Cargando árbol...
</div>
);
}
return (
<div className="w-full h-full relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.4 }}
minZoom={0.3}
maxZoom={1.5}
nodesDraggable={false}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--muted-foreground) / 0.15)" />
<Controls />
</ReactFlow>
{selectedNodeId && (
<SkillNodeDetail
nodeId={selectedNodeId}
onClose={() => setSelectedNodeId(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,366 @@
'use client';
import {
useState,
useRef,
useCallback,
useEffect,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { Pencil, Type, Eraser, Trash2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
type Tool = 'pen' | 'text' | 'eraser';
interface TextBox {
id: string;
x: number;
y: number;
width: number;
height: number;
text: string;
}
const COLORS = ['#ffffff', '#ef4444', '#22c55e', '#3b82f6', '#eab308', '#a855f7'];
const SIZES = [2, 4, 6];
export function Scratchpad() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [tool, setTool] = useState<Tool>('pen');
const [color, setColor] = useState('#ffffff');
const [strokeSize, setStrokeSize] = useState(2);
const [isDrawing, setIsDrawing] = useState(false);
const [textBoxes, setTextBoxes] = useState<TextBox[]>([]);
const [dragging, setDragging] = useState<{ id: string; offsetX: number; offsetY: number } | null>(null);
const [resizing, setResizing] = useState<{ id: string; startW: number; startH: number; startX: number; startY: number } | null>(null);
const lastPoint = useRef<{ x: number; y: number } | null>(null);
// Setup canvas resolution
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const resize = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Save current drawing
const imageData = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height);
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(dpr, dpr);
// Restore drawing if dimensions haven't changed too much
if (imageData) {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = imageData.width;
tempCanvas.height = imageData.height;
tempCanvas.getContext('2d')?.putImageData(imageData, 0, 0);
ctx.drawImage(tempCanvas, 0, 0, imageData.width / dpr, imageData.height / dpr);
}
}
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
return () => observer.disconnect();
}, []);
const getPos = useCallback((e: ReactPointerEvent | globalThis.PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}, []);
const drawLine = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.strokeStyle = tool === 'eraser' ? '#0a0a0a' : color;
ctx.lineWidth = tool === 'eraser' ? strokeSize * 6 : strokeSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
},
[color, strokeSize, tool]
);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>) => {
if (tool === 'text') {
// Place a new text box at click position
const pos = getPos(e);
const newBox: TextBox = {
id: crypto.randomUUID(),
x: pos.x,
y: pos.y,
width: 160,
height: 60,
text: '',
};
setTextBoxes((prev) => [...prev, newBox]);
setTool('pen'); // Switch back to pen after placing
return;
}
setIsDrawing(true);
lastPoint.current = getPos(e);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[tool, getPos]
);
const handlePointerMove = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>) => {
if (!isDrawing || !lastPoint.current) return;
const pos = getPos(e);
drawLine(lastPoint.current, pos);
lastPoint.current = pos;
},
[isDrawing, getPos, drawLine]
);
const handlePointerUp = useCallback(() => {
setIsDrawing(false);
lastPoint.current = null;
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
setTextBoxes([]);
}, []);
const deleteTextBox = useCallback((id: string) => {
setTextBoxes((prev) => prev.filter((b) => b.id !== id));
}, []);
// Global pointer move/up for dragging text boxes
useEffect(() => {
if (!dragging && !resizing) return;
// Prevent text selection while dragging/resizing
const preventSelect = (e: Event) => e.preventDefault();
document.addEventListener('selectstart', preventSelect);
const handleMove = (e: globalThis.PointerEvent) => {
if (dragging) {
setTextBoxes((prev) =>
prev.map((b) =>
b.id === dragging.id
? { ...b, x: e.clientX - (containerRef.current?.getBoundingClientRect().left ?? 0) - dragging.offsetX, y: e.clientY - (containerRef.current?.getBoundingClientRect().top ?? 0) - dragging.offsetY }
: b
)
);
}
if (resizing) {
const dx = e.clientX - resizing.startX;
const dy = e.clientY - resizing.startY;
setTextBoxes((prev) =>
prev.map((b) =>
b.id === resizing.id
? { ...b, width: Math.max(80, resizing.startW + dx), height: Math.max(30, resizing.startH + dy) }
: b
)
);
}
};
const handleUp = () => {
setDragging(null);
setResizing(null);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
return () => {
document.removeEventListener('selectstart', preventSelect);
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
}, [dragging, resizing]);
return (
<div className="h-full flex flex-col bg-[#0a0a0a] border-l border-border">
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-card/50 flex-wrap">
<Button
variant={tool === 'pen' ? 'default' : 'ghost'}
size="icon"
className="w-7 h-7"
onClick={() => setTool('pen')}
title="Lápiz"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant={tool === 'text' ? 'default' : 'ghost'}
size="icon"
className="w-7 h-7"
onClick={() => setTool('text')}
title="Texto"
>
<Type className="w-3.5 h-3.5" />
</Button>
<Button
variant={tool === 'eraser' ? 'default' : 'ghost'}
size="icon"
className="w-7 h-7"
onClick={() => setTool('eraser')}
title="Borrador"
>
<Eraser className="w-3.5 h-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
{/* Colors */}
{COLORS.map((c) => (
<button
key={c}
onClick={() => { setColor(c); if (tool === 'eraser') setTool('pen'); }}
className={`w-5 h-5 rounded-full border-2 transition-transform ${
color === c && tool !== 'eraser' ? 'border-primary scale-125' : 'border-transparent hover:scale-110'
}`}
style={{ backgroundColor: c }}
/>
))}
<div className="w-px h-5 bg-border mx-1" />
{/* Stroke size */}
{SIZES.map((s) => (
<button
key={s}
onClick={() => setStrokeSize(s)}
className={`w-6 h-6 flex items-center justify-center rounded transition-colors ${
strokeSize === s ? 'bg-primary/20' : 'hover:bg-muted'
}`}
title={`Grosor ${s}`}
>
<div
className="rounded-full bg-foreground"
style={{ width: s + 2, height: s + 2 }}
/>
</button>
))}
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className="w-7 h-7 text-destructive hover:text-destructive"
onClick={clearCanvas}
title="Limpiar todo"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
{/* Canvas area */}
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
<canvas
ref={canvasRef}
className="absolute inset-0"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{ touchAction: 'none' }}
/>
{/* Text boxes */}
{textBoxes.map((box) => (
<div
key={box.id}
className="absolute group"
style={{
left: box.x,
top: box.y,
width: box.width,
height: box.height,
}}
>
{/* Drag handle */}
<div
className="absolute -top-5 left-0 right-0 h-5 flex items-center justify-between px-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-move"
onPointerDown={(e) => {
e.stopPropagation();
setDragging({ id: box.id, offsetX: e.nativeEvent.offsetX, offsetY: e.nativeEvent.offsetY + 20 });
}}
>
<div className="flex items-center gap-0.5">
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
<div className="w-1 h-1 rounded-full bg-muted-foreground" />
</div>
<button
className="text-destructive hover:text-destructive/80"
onClick={() => deleteTextBox(box.id)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
<textarea
value={box.text}
onChange={(e) =>
setTextBoxes((prev) =>
prev.map((b) => (b.id === box.id ? { ...b, text: e.target.value } : b))
)
}
placeholder="Escribe aquí..."
className="w-full h-full bg-white/5 border border-white/20 rounded text-sm text-foreground p-2 resize-none focus:outline-none focus:border-primary/50 placeholder:text-muted-foreground/40"
style={{ fontSize: 13 }}
onKeyDown={(e) => e.stopPropagation()}
/>
{/* Resize handle */}
<div
className="absolute -bottom-1 -right-1 w-3 h-3 cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
onPointerDown={(e) => {
e.stopPropagation();
setResizing({
id: box.id,
startW: box.width,
startH: box.height,
startX: e.clientX,
startY: e.clientY,
});
}}
>
<svg viewBox="0 0 10 10" className="w-full h-full text-muted-foreground">
<path d="M9 1L1 9M9 5L5 9" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</div>
</div>
))}
{/* Tool indicator */}
{tool === 'text' && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 px-3 py-1 bg-card/80 border border-border rounded-full text-xs text-muted-foreground pointer-events-none">
Haz clic para colocar un cuadro de texto
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,433 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Lightbulb, Clock, CheckCircle2, XCircle, ArrowRight, Eye, PenTool, BookOpen, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Challenge } from '@/types/challenge';
import { verifyAnswer } from '@/lib/challenge-engine/verifier';
import { useProgressStore } from '@/stores/useProgressStore';
import { getChallengesForNode } from '@/data/challenges/math';
import { MathInput } from './modules/MathInput';
import { MultipleChoice } from './modules/MultipleChoice';
import { Scratchpad } from './Scratchpad';
const MAX_ATTEMPTS_BEFORE_REVEAL = 3;
interface WorkbenchShellProps {
challenge: Challenge;
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-mono font-medium bg-muted border border-border rounded text-muted-foreground">
{children}
</kbd>
);
}
type Phase = 'answering' | 'wrong-shake' | 'correct' | 'revealed';
export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
const router = useRouter();
const completeChallenge = useProgressStore((s) => s.completeChallenge);
const completedChallenges = useProgressStore((s) => s.completedChallenges);
const [answer, setAnswer] = useState<string | number>('');
const [phase, setPhase] = useState<Phase>('answering');
const [attempts, setAttempts] = useState(0);
const [showHint, setShowHint] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
const [elapsedTime, setElapsedTime] = useState(0);
const [canProceed, setCanProceed] = useState(false);
const shakeTimeout = useRef<ReturnType<typeof setTimeout>>(null);
const [showScratchpad, setShowScratchpad] = useState(true);
const [showExplanation, setShowExplanation] = useState(!!challenge.explanation);
const isAlreadyCompleted = !!completedChallenges[challenge.id];
const solved = phase === 'correct';
const revealed = phase === 'revealed';
const done = solved || revealed;
const getNextChallenge = useCallback((): Challenge | null => {
const nodeChallenges = getChallengesForNode(challenge.nodeId);
const currentIndex = nodeChallenges.findIndex((c) => c.id === challenge.id);
for (let i = currentIndex + 1; i < nodeChallenges.length; i++) {
if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
}
for (let i = 0; i < currentIndex; i++) {
if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
}
return null;
}, [challenge.id, challenge.nodeId, completedChallenges]);
// Timer
useEffect(() => {
if (done) return;
const interval = setInterval(() => setElapsedTime((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [done]);
// Cleanup shake timeout
useEffect(() => {
return () => { if (shakeTimeout.current) clearTimeout(shakeTimeout.current); };
}, []);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
const handleSubmit = useCallback(() => {
if (answer === '' || answer === undefined || done) return;
const verification = verifyAnswer(challenge, answer);
if (verification.correct) {
setPhase('correct');
setCanProceed(false);
setTimeout(() => setCanProceed(true), 800);
if (!isAlreadyCompleted) {
completeChallenge(challenge.id, elapsedTime);
}
} else {
const newAttempts = attempts + 1;
setAttempts(newAttempts);
// Brief shake feedback, then clear input to try again
setPhase('wrong-shake');
shakeTimeout.current = setTimeout(() => {
setPhase('answering');
setAnswer('');
}, 600);
}
}, [answer, challenge, completeChallenge, elapsedTime, isAlreadyCompleted, attempts, done]);
const handleRevealSolution = useCallback(() => {
setPhase('revealed');
setCanProceed(false);
setTimeout(() => setCanProceed(true), 800);
}, []);
const handleNextChallenge = useCallback(() => {
const next = getNextChallenge();
if (next) {
router.push(`/workbench/${encodeURIComponent(next.id)}`);
} else {
router.push('/skill-tree');
}
}, [getNextChallenge, router]);
const handleBack = () => {
router.push('/skill-tree');
};
const handleNextHint = () => {
if (hintIndex < challenge.hints.length - 1) {
setHintIndex((i) => i + 1);
}
setShowHint(true);
};
const getSolutionText = (): string => {
if (challenge.content.type === 'math-input') {
return String(challenge.content.answer.value);
}
if (challenge.content.type === 'multiple-choice') {
return challenge.content.options[challenge.content.correctIndex];
}
return '';
};
// Global keyboard handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === 'INPUT' || tag === 'TEXTAREA';
if (e.key === 'Enter') {
e.preventDefault();
if (!done && phase !== 'wrong-shake') {
handleSubmit();
} else if (done && canProceed) {
handleNextChallenge();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
handleBack();
return;
}
// H for hint
if (e.key === 'h' && !isInput && !done && phase !== 'wrong-shake') {
e.preventDefault();
handleNextHint();
return;
}
// S to reveal solution (only when enough attempts)
if (e.key === 's' && !isInput && !done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL) {
e.preventDefault();
handleRevealSolution();
return;
}
// Multiple choice: number keys 1-9
if (challenge.content.type === 'multiple-choice' && !isInput && !done && phase !== 'wrong-shake') {
const num = parseInt(e.key);
if (num >= 1 && num <= challenge.content.options.length) {
e.preventDefault();
setAnswer(num - 1);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [phase, done, canProceed, attempts, handleSubmit, handleNextChallenge, handleRevealSolution, challenge.content]);
const nextChallenge = getNextChallenge();
return (
<div className="h-full flex flex-col">
{/* Top bar */}
<header className="px-4 py-3 border-b border-border bg-card/50 backdrop-blur-sm flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title="Volver (Esc)">
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-base font-bold">{challenge.title}</h1>
<p className="text-xs text-muted-foreground">+{challenge.xpReward} XP</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
{formatTime(elapsedTime)}
</div>
{attempts > 0 && !done && (
<div className="text-xs text-muted-foreground">
Intento {attempts}
</div>
)}
{isAlreadyCompleted && (
<div className="flex items-center gap-1 text-green-500 text-sm">
<CheckCircle2 className="w-4 h-4" />
Completado
</div>
)}
<Button
variant={showScratchpad ? 'default' : 'ghost'}
size="icon"
className="w-8 h-8"
onClick={() => setShowScratchpad((v) => !v)}
title="Libreta"
>
<PenTool className="w-4 h-4" />
</Button>
</div>
</header>
{/* Split layout: challenge + scratchpad */}
<div className="flex-1 flex overflow-hidden">
{/* Challenge panel */}
<div className={`overflow-auto p-4 md:p-8 ${showScratchpad ? 'w-1/2' : 'w-full'} transition-all`}>
<div className="max-w-2xl mx-auto space-y-6">
{/* Explanation / Theory */}
{challenge.explanation && (
<Card className="border-blue-500/20 bg-blue-500/5 overflow-hidden">
<button
onClick={() => setShowExplanation((v) => !v)}
className="w-full px-6 py-4 flex items-center gap-2 text-left hover:bg-blue-500/5 transition-colors"
>
<BookOpen className="w-4 h-4 text-blue-400 shrink-0" />
<h2 className="text-sm font-semibold text-blue-400 flex-1">Aprende primero</h2>
{showExplanation ? (
<ChevronUp className="w-4 h-4 text-blue-400" />
) : (
<ChevronDown className="w-4 h-4 text-blue-400" />
)}
</button>
{showExplanation && (
<div className="px-6 pb-5 pt-0">
<div className="text-sm leading-relaxed whitespace-pre-line text-foreground/85">
{challenge.explanation}
</div>
</div>
)}
</Card>
)}
{/* Problem statement */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-2">Problema</h2>
<p className="text-base leading-relaxed">{challenge.description}</p>
</Card>
{/* Workbench module */}
<Card className={`p-6 transition-all ${phase === 'wrong-shake' ? 'animate-shake border-red-500/50' : ''}`}>
<h2 className="text-lg font-semibold mb-4">Tu respuesta</h2>
{challenge.content.type === 'math-input' && (
<MathInput
value={answer as string}
onChange={setAnswer}
onSubmit={handleSubmit}
disabled={done || phase === 'wrong-shake'}
/>
)}
{challenge.content.type === 'multiple-choice' && (
<MultipleChoice
options={challenge.content.options}
selected={answer as number}
onChange={setAnswer}
disabled={done || phase === 'wrong-shake'}
/>
)}
{/* Wrong attempt feedback inline */}
{phase === 'wrong-shake' && (
<p className="text-sm text-red-400 mt-3 text-center">
Incorrecto. Inténtalo de nuevo.
</p>
)}
</Card>
{/* Hint section */}
{challenge.hints.length > 0 && !done && phase !== 'wrong-shake' && (
<div>
{showHint ? (
<Card className="p-4 bg-amber-500/5 border-amber-500/20">
<div className="flex items-start gap-2">
<Lightbulb className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-amber-500 mb-1">
Pista {hintIndex + 1}/{challenge.hints.length}
</p>
<p className="text-sm">{challenge.hints[hintIndex]}</p>
{hintIndex < challenge.hints.length - 1 && (
<Button
variant="ghost"
size="sm"
className="mt-2 text-amber-500 hover:text-amber-400"
onClick={handleNextHint}
>
Siguiente pista <Kbd>H</Kbd>
</Button>
)}
</div>
</div>
</Card>
) : (
<Button
variant="outline"
size="sm"
className="text-amber-500 border-amber-500/30 hover:bg-amber-500/10"
onClick={handleNextHint}
>
<Lightbulb className="w-4 h-4 mr-1" />
Mostrar pista <Kbd>H</Kbd>
</Button>
)}
</div>
)}
{/* Reveal solution button — only after enough failed attempts */}
{!done && phase !== 'wrong-shake' && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && (
<Button
variant="outline"
size="sm"
className="text-muted-foreground border-border hover:bg-muted"
onClick={handleRevealSolution}
>
<Eye className="w-4 h-4 mr-1" />
Ver solución <Kbd>S</Kbd>
</Button>
)}
{/* Result feedback — correct */}
{solved && (
<Card className="p-4 bg-green-500/10 border-green-500/30">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<p className="font-medium">¡Correcto! 🎉</p>
</div>
{!isAlreadyCompleted && (
<p className="text-sm text-green-500 mt-1 ml-7">
+{challenge.xpReward} XP ganados
{attempts === 0 && ' — ¡a la primera!'}
</p>
)}
</Card>
)}
{/* Result feedback — revealed */}
{revealed && (
<Card className="p-4 bg-muted/50 border-border">
<div className="flex items-center gap-2 mb-2">
<Eye className="w-5 h-5 text-muted-foreground" />
<p className="font-medium text-muted-foreground">Solución</p>
</div>
<p className="text-lg font-mono font-bold ml-7">{getSolutionText()}</p>
<p className="text-xs text-muted-foreground mt-2 ml-7">
No se otorga XP al revelar la solución. Intenta recordarla para la próxima.
</p>
</Card>
)}
{/* Submit / Continue */}
<div className="flex gap-3">
{!done && phase !== 'wrong-shake' ? (
<Button
onClick={handleSubmit}
disabled={answer === '' || answer === undefined}
className="flex-1"
size="lg"
>
Verificar respuesta <Kbd>Enter</Kbd>
</Button>
) : done ? (
<>
<Button
onClick={handleNextChallenge}
className="flex-1"
size="lg"
disabled={!canProceed}
autoFocus
>
{nextChallenge ? 'Siguiente reto' : 'Volver al árbol'}
<ArrowRight className="w-4 h-4 ml-2" />
<Kbd>Enter</Kbd>
</Button>
{nextChallenge && (
<Button onClick={handleBack} variant="outline" size="lg">
Árbol <Kbd>Esc</Kbd>
</Button>
)}
</>
) : null}
</div>
{/* Keyboard shortcuts legend */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground justify-center pt-2">
<span><Kbd>Enter</Kbd> Verificar / Siguiente</span>
<span><Kbd>Esc</Kbd> Volver al árbol</span>
{challenge.hints.length > 0 && !done && <span><Kbd>H</Kbd> Pista</span>}
{!done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && <span><Kbd>S</Kbd> Ver solución</span>}
{challenge.content.type === 'multiple-choice' && !done && <span><Kbd>1</Kbd>-<Kbd>{challenge.content.options.length}</Kbd> Seleccionar</span>}
</div>
</div>
</div>
{/* Scratchpad panel */}
{showScratchpad && (
<div className="w-1/2 shrink-0">
<Scratchpad />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
interface MathInputProps {
value: string;
onChange: (val: string) => void;
onSubmit: () => void;
disabled?: boolean;
}
export function MathInput({ value, onChange, onSubmit, disabled }: MathInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [rtl, setRtl] = useState(false);
useEffect(() => {
if (!disabled) inputRef.current?.focus();
}, [disabled]);
// In RTL mode we intercept all key input manually.
// Each new digit is prepended (inserted at the left).
// Backspace removes the leftmost digit.
// The stored `value` is always the final number in normal reading order.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'Enter' && value !== '') {
e.preventDefault();
onSubmit();
return;
}
if (!rtl) return; // In LTR mode, let the browser handle it normally
// Prevent default so we control all input
const isDigit = /^[0-9]$/.test(e.key);
const isDot = e.key === '.';
const isMinus = e.key === '-';
const isBackspace = e.key === 'Backspace';
if (isDigit || isDot || isMinus) {
e.preventDefault();
// Prepend the character
onChange(e.key + value);
} else if (isBackspace) {
e.preventDefault();
// Remove the first character (the last one typed)
onChange(value.slice(1));
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
// Block any other printable character
e.preventDefault();
}
},
[disabled, rtl, value, onChange, onSubmit]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled || rtl) return; // RTL is handled via keydown
onChange(e.target.value);
},
[disabled, rtl, onChange]
);
const handleToggle = useCallback(() => {
setRtl((prev) => !prev);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
return (
<div className="space-y-3">
<input
ref={inputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={rtl ? '...escribe desde las unidades' : 'Escribe tu respuesta...'}
className={`w-full px-4 py-3 text-2xl font-mono rounded-lg bg-muted border-2 border-border focus:border-primary focus:outline-none transition-colors disabled:opacity-60 ${
rtl ? 'text-right' : 'text-center'
}`}
/>
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={handleToggle}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-colors ${
rtl
? 'bg-primary/15 text-primary border border-primary/30'
: 'bg-muted text-muted-foreground border border-border hover:text-foreground'
}`}
>
<span className="font-mono font-bold text-sm">{rtl ? '←' : '→'}</span>
{rtl ? 'Derecha a izquierda' : 'Izquierda a derecha'}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
interface MultipleChoiceProps {
options: string[];
selected: number | string;
onChange: (val: number) => void;
disabled?: boolean;
}
export function MultipleChoice({ options, selected, onChange, disabled }: MultipleChoiceProps) {
return (
<div className="space-y-2">
{options.map((option, index) => {
const isSelected = selected === index;
return (
<button
key={index}
onClick={() => !disabled && onChange(index)}
disabled={disabled}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
isSelected
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-muted/30 text-foreground hover:border-primary/30 hover:bg-muted/50'
} disabled:opacity-60 disabled:cursor-not-allowed`}
>
<div className="flex items-center gap-3">
<div
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 text-[10px] font-mono font-bold ${
isSelected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/30 text-muted-foreground'
}`}
>
{isSelected ? (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
) : (
index + 1
)}
</div>
<span className="text-sm font-medium">{option}</span>
</div>
</button>
);
})}
</div>
);
}

204
src/data/challenges/math.ts Normal file
View File

@@ -0,0 +1,204 @@
import { Challenge } from '@/types/challenge';
function mathChallenge(
id: string,
nodeId: string,
title: string,
problem: string,
answer: number,
difficulty: 1 | 2 | 3 | 4 | 5 = 1,
hints: string[] = [],
tolerance = 0,
explanation?: string
): Challenge {
return {
id: `${nodeId}/${id}`,
nodeId,
title,
description: problem,
difficulty,
type: 'math-input',
hints,
xpReward: difficulty * 20,
explanation,
content: {
type: 'math-input',
problem,
answer: { type: 'numeric', value: answer, tolerance },
},
};
}
export const arithmeticChallenges: Challenge[] = [
// Addition
mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0,
'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 7 + 5: empieza en 7 y cuenta 5 más → 8, 9, 10, 11, 12.'),
mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']),
mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']),
mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']),
// Subtraction
mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0,
'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 10 galletas y comes 3, te quedan 10 - 3 = 7.\n\nTruco: puedes pensar "¿qué le sumo a 8 para llegar a 15?". Si 8 + 7 = 15, entonces 15 - 8 = 7.'),
mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']),
mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1),
// Multiplication
mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0,
'La multiplicación es una suma repetida. 6 × 7 significa "sumar 7 seis veces" (o "sumar 6 siete veces").\n\n6 × 7 = 7 + 7 + 7 + 7 + 7 + 7 = 42\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 6×7, piensa en 6×5=30 y luego suma 6×2=12 → 30+12=42.'),
mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']),
mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']),
// Division
mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0,
'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n56 ÷ 8 significa: "¿en cuántos grupos de 8 cabe 56?" o "si reparto 56 entre 8, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 8 da 56?". Como 7 × 8 = 56, entonces 56 ÷ 8 = 7.'),
mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1),
mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01),
];
export const fractionChallenges: Challenge[] = [
mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01,
'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/3 = 2/6 (multiplica arriba y abajo por 2)\n• 2/6 + 1/6 = 3/6 = 1/2 = 0.5\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'),
mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01),
mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001),
];
export const decimalChallenges: Challenge[] = [
mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01,
'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 3.70\n+ 2.85\n------\n 6.55\n\nTruco: si un número tiene menos decimales, añade ceros al final (3.7 → 3.70) para que sea más fácil alinearlos.'),
mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001),
mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']),
];
export const percentageChallenges: Challenge[] = [
mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0,
'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 25% de 200 = 0.25 × 200 = 50\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'),
mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']),
mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']),
];
export const primeChallenges: Challenge[] = [
mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0,
'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEjemplos:\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 7 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'),
mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']),
mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']),
];
export const gcdLcmChallenges: Challenge[] = [
mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0,
'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 24 = 2 × 2 × 2 × 3 = 2³ × 3\n • 36 = 2 × 2 × 3 × 3 = 2² × 3²\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(3,2) = 2² = 4\n • Factor 3: mín(1,2) = 3¹ = 3\n3. Multiplica: 4 × 3 = 12\n\nEl MCD de 24 y 36 es 12.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 36 ÷ 24 = 1 resto 12\n• 24 ÷ 12 = 2 resto 0 → MCD = 12'),
mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0,
'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 6: 6, 12, 18, 24, 30...\n• Múltiplos de 8: 8, 16, 24, 32...\n• El primero en común es 24\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(6,8) = (6 × 8) / MCD(6,8) = 48 / 2 = 24\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'),
mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2),
];
export const variableChallenges: Challenge[] = [
mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0,
'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"2x" significa "2 multiplicado por x". Si x = 3:\n• 2x = 2 × 3 = 6\n• 2x + 5 = 6 + 5 = 11\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'),
mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']),
mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']),
];
export const equationChallenges: Challenge[] = [
mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0,
'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 7 = 15\n• Queremos x sola → restamos 7 de ambos lados\n• x + 7 - 7 = 15 - 7\n• x = 8\n\nComprobación: 8 + 7 = 15 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'),
mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']),
mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']),
mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']),
];
export const linearSystemChallenges: Challenge[] = [
mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0,
'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 10\n x - y = 2\n\nSumando ambas: (x+y) + (x-y) = 10+2 → 2x = 12 → x = 6\nSustituyendo: 6 + y = 10 → y = 4'),
mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']),
mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']),
];
export const quadraticChallenges: Challenge[] = [
mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0,
'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 25 → x = +5 o x = -5 (porque tanto 5×5 como (-5)×(-5) dan 25)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'),
mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']),
mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']),
];
export const booleanChallenges: Challenge[] = [
{
id: 'logic.boolean/bool-01',
nodeId: 'logic.boolean',
title: 'AND básico',
description: 'En lógica booleana, ¿cuál es el resultado de TRUE AND FALSE?',
difficulty: 2,
type: 'multiple-choice',
hints: ['AND solo es TRUE cuando ambos operandos son TRUE'],
xpReward: 40,
explanation: 'La lógica booleana trabaja con dos valores: TRUE (verdadero) y FALSE (falso).\n\nOperadores básicos:\n\n• AND (Y): Solo es TRUE si AMBOS son TRUE\n TRUE AND TRUE = TRUE\n TRUE AND FALSE = FALSE\n FALSE AND FALSE = FALSE\n\n• OR (O): Es TRUE si AL MENOS UNO es TRUE\n TRUE OR FALSE = TRUE\n FALSE OR FALSE = FALSE\n\n• NOT (NO): Invierte el valor\n NOT TRUE = FALSE\n NOT FALSE = TRUE\n\nEstos operadores son la base de toda la computación y los circuitos digitales.',
content: {
type: 'multiple-choice',
question: '¿Cuál es el resultado de TRUE AND FALSE?',
options: ['TRUE', 'FALSE', 'NULL', 'ERROR'],
correctIndex: 1,
},
},
{
id: 'logic.boolean/bool-02',
nodeId: 'logic.boolean',
title: 'OR básico',
description: '¿Cuál es el resultado de FALSE OR TRUE?',
difficulty: 2,
type: 'multiple-choice',
hints: ['OR es TRUE cuando al menos uno es TRUE'],
xpReward: 40,
content: {
type: 'multiple-choice',
question: '¿Cuál es el resultado de FALSE OR TRUE?',
options: ['TRUE', 'FALSE'],
correctIndex: 0,
},
},
{
id: 'logic.boolean/bool-03',
nodeId: 'logic.boolean',
title: 'NOT y combinaciones',
description: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
difficulty: 2,
type: 'multiple-choice',
hints: ['Primero evalúa TRUE AND FALSE, luego aplica NOT'],
xpReward: 40,
content: {
type: 'multiple-choice',
question: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
options: ['TRUE', 'FALSE'],
correctIndex: 0,
},
},
];
export const binaryChallenges: Challenge[] = [
mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0,
'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 13 = 8 + 4 + 1\n• 13 = 1×8 + 1×4 + 0×2 + 1×1\n• 13 en binario = 1101\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 13÷2 = 6 resto 1\n• 6÷2 = 3 resto 0\n• 3÷2 = 1 resto 1\n• 1÷2 = 0 resto 1\n→ 1101'),
mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']),
mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']),
];
export const allChallenges: Challenge[] = [
...arithmeticChallenges,
...fractionChallenges,
...decimalChallenges,
...percentageChallenges,
...primeChallenges,
...gcdLcmChallenges,
...variableChallenges,
...equationChallenges,
...linearSystemChallenges,
...quadraticChallenges,
...booleanChallenges,
...binaryChallenges,
];
export function getChallengeById(id: string): Challenge | undefined {
return allChallenges.find((c) => c.id === id);
}
export function getChallengesForNode(nodeId: string): Challenge[] {
return allChallenges.filter((c) => c.nodeId === nodeId);
}

256
src/data/skill-tree.ts Normal file
View File

@@ -0,0 +1,256 @@
import { SkillNode, SkillEdge } from '@/types/skill-tree';
// Layout constants
const ROW_GAP = 180; // vertical spacing between rows
const COL_GAP = 250; // horizontal spacing between nodes
const CENTER = 300; // center x for single nodes
// Row helper: y position for each tier
const row = (n: number) => n * ROW_GAP;
export const skillNodes: SkillNode[] = [
// === ROW 0: Root ===
{
id: 'arithmetic.addition',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Suma',
description: 'Aprende a sumar números enteros',
icon: '',
position: { x: CENTER, y: row(0) },
prerequisites: [],
challenges: ['add-01', 'add-02', 'add-03', 'add-04'],
difficulty: 1,
},
// === ROW 1: Suma branches into Resta & Multiplicación ===
{
id: 'arithmetic.subtraction',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Resta',
description: 'Aprende a restar números enteros',
icon: '',
position: { x: CENTER - COL_GAP / 2, y: row(1) },
prerequisites: ['arithmetic.addition'],
challenges: ['sub-01', 'sub-02', 'sub-03'],
difficulty: 1,
},
{
id: 'arithmetic.multiplication',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Multiplicación',
description: 'Domina las tablas de multiplicar y más',
icon: '✖️',
position: { x: CENTER + COL_GAP / 2, y: row(1) },
prerequisites: ['arithmetic.addition'],
challenges: ['mul-01', 'mul-02', 'mul-03'],
difficulty: 1,
},
// === ROW 2: División (from Multiplicación) ===
{
id: 'arithmetic.division',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'División',
description: 'Divide números enteros con y sin resto',
icon: '➗',
position: { x: CENTER + COL_GAP / 2, y: row(2) },
prerequisites: ['arithmetic.multiplication'],
challenges: ['div-01', 'div-02', 'div-03'],
difficulty: 1,
},
// === ROW 3: Branches from Resta+División & División ===
{
id: 'arithmetic.fractions',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Fracciones',
description: 'Opera con fracciones: suma, resta, multiplicación y división',
icon: '½',
position: { x: CENTER - COL_GAP, y: row(3) },
prerequisites: ['arithmetic.subtraction', 'arithmetic.division'],
challenges: ['frac-01', 'frac-02', 'frac-03'],
difficulty: 2,
},
{
id: 'arithmetic.decimals',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Decimales',
description: 'Trabaja con números decimales y redondeo',
icon: '0.5',
position: { x: CENTER, y: row(3) },
prerequisites: ['arithmetic.division'],
challenges: ['dec-01', 'dec-02', 'dec-03'],
difficulty: 2,
},
{
id: 'number-theory.gcd-lcm',
discipline: 'mathematics',
branch: 'number-theory',
title: 'MCD y MCM',
description: 'Máximo común divisor y mínimo común múltiplo',
icon: '🔗',
position: { x: CENTER + COL_GAP, y: row(3) },
prerequisites: ['arithmetic.division'],
challenges: ['gcd-01', 'gcd-02', 'gcd-03'],
difficulty: 2,
},
// === ROW 4: Porcentajes & Primos ===
{
id: 'arithmetic.percentages',
discipline: 'mathematics',
branch: 'arithmetic',
title: 'Porcentajes',
description: 'Calcula porcentajes, descuentos y aumentos',
icon: '%',
position: { x: CENTER - COL_GAP / 2, y: row(4) },
prerequisites: ['arithmetic.fractions', 'arithmetic.decimals'],
challenges: ['pct-01', 'pct-02', 'pct-03'],
difficulty: 2,
},
{
id: 'number-theory.primes',
discipline: 'mathematics',
branch: 'number-theory',
title: 'Números Primos',
description: 'Identifica primos y comprende la factorización',
icon: '🔢',
position: { x: CENTER + COL_GAP, y: row(4) },
prerequisites: ['arithmetic.division'],
challenges: ['prime-01', 'prime-02', 'prime-03'],
difficulty: 2,
},
// === ROW 5: Algebra starts & Logic starts ===
{
id: 'algebra.variables',
discipline: 'mathematics',
branch: 'algebra',
title: 'Variables y Expresiones',
description: 'Introduce variables y evalúa expresiones algebraicas',
icon: '𝑥',
position: { x: CENTER - COL_GAP / 2, y: row(5) },
prerequisites: ['arithmetic.percentages'],
challenges: ['var-01', 'var-02', 'var-03'],
difficulty: 2,
},
{
id: 'logic.boolean',
discipline: 'logic',
branch: 'logic',
title: 'Lógica Booleana',
description: 'AND, OR, NOT — las bases del pensamiento lógico',
icon: '🧠',
position: { x: CENTER + COL_GAP, y: row(5) },
prerequisites: ['number-theory.primes'],
challenges: ['bool-01', 'bool-02', 'bool-03'],
difficulty: 2,
},
// === ROW 6: Ecuaciones & Binario ===
{
id: 'algebra.equations',
discipline: 'mathematics',
branch: 'algebra',
title: 'Ecuaciones Lineales',
description: 'Resuelve ecuaciones de primer grado',
icon: '⚖️',
position: { x: CENTER - COL_GAP / 2, y: row(6) },
prerequisites: ['algebra.variables'],
challenges: ['eq-01', 'eq-02', 'eq-03', 'eq-04'],
difficulty: 3,
},
{
id: 'logic.binary',
discipline: 'logic',
branch: 'logic',
title: 'Sistema Binario',
description: 'Convierte entre decimal, binario y hexadecimal',
icon: '0⃣1⃣',
position: { x: CENTER + COL_GAP, y: row(6) },
prerequisites: ['logic.boolean'],
challenges: ['bin-01', 'bin-02', 'bin-03'],
difficulty: 2,
},
// === ROW 7: Algebra branches ===
{
id: 'algebra.linear-systems',
discipline: 'mathematics',
branch: 'algebra',
title: 'Sistemas Lineales',
description: 'Resuelve sistemas de dos ecuaciones con dos incógnitas',
icon: '📐',
position: { x: CENTER - COL_GAP, y: row(7) },
prerequisites: ['algebra.equations'],
challenges: ['sys-01', 'sys-02', 'sys-03'],
difficulty: 3,
},
{
id: 'algebra.quadratics',
discipline: 'mathematics',
branch: 'algebra',
title: 'Ecuaciones Cuadráticas',
description: 'Resuelve ecuaciones de segundo grado con la fórmula general',
icon: '📈',
position: { x: CENTER, y: row(7) },
prerequisites: ['algebra.equations'],
challenges: ['quad-01', 'quad-02', 'quad-03'],
difficulty: 3,
},
];
export const skillEdges: SkillEdge[] = skillNodes
.flatMap((node) =>
node.prerequisites.map((prereq) => ({
from: prereq,
to: node.id,
type: 'hard' as const,
}))
);
export function getNodeById(id: string): SkillNode | undefined {
return skillNodes.find((n) => n.id === id);
}
export function getAvailableNodes(completedNodeIds: string[]): string[] {
return skillNodes
.filter(
(node) =>
!completedNodeIds.includes(node.id) &&
node.prerequisites.every((prereq) => completedNodeIds.includes(prereq))
)
.map((n) => n.id);
}
export function getNodeStatus(
nodeId: string,
completedNodeIds: string[],
completedChallengeIds: string[]
): 'locked' | 'available' | 'in-progress' | 'completed' {
const node = getNodeById(nodeId);
if (!node) return 'locked';
const allChallengesCompleted = node.challenges.every((c) =>
completedChallengeIds.includes(`${nodeId}/${c}`)
);
if (allChallengesCompleted) return 'completed';
const someChallengesCompleted = node.challenges.some((c) =>
completedChallengeIds.includes(`${nodeId}/${c}`)
);
if (someChallengesCompleted) return 'in-progress';
const prerequisitesMet = node.prerequisites.every((prereq) =>
completedNodeIds.includes(prereq)
);
if (prerequisitesMet || node.prerequisites.length === 0) return 'available';
return 'locked';
}

View File

@@ -0,0 +1,55 @@
import { Challenge, VerificationResult } from '@/types/challenge';
export function verifyAnswer(
challenge: Challenge,
userAnswer: string | number
): VerificationResult {
const { content } = challenge;
switch (content.type) {
case 'math-input':
return verifyMathInput(content, userAnswer, challenge.xpReward);
case 'multiple-choice':
return verifyMultipleChoice(content, userAnswer, challenge.xpReward);
default:
return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 };
}
}
function verifyMathInput(
content: { answer: { type: string; value: number | string; tolerance?: number } },
userAnswer: string | number,
xpReward: number
): VerificationResult {
const numericAnswer = typeof userAnswer === 'string' ? parseFloat(userAnswer) : userAnswer;
if (isNaN(numericAnswer)) {
return { correct: false, message: 'Por favor ingresa un número válido', xpEarned: 0 };
}
const expected = typeof content.answer.value === 'string'
? parseFloat(content.answer.value)
: content.answer.value;
const tolerance = content.answer.tolerance ?? 0;
if (Math.abs(numericAnswer - expected) <= tolerance) {
return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
}
return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
}
function verifyMultipleChoice(
content: { correctIndex: number },
userAnswer: string | number,
xpReward: number
): VerificationResult {
const selectedIndex = typeof userAnswer === 'string' ? parseInt(userAnswer) : userAnswer;
if (selectedIndex === content.correctIndex) {
return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
}
return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
}

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

47
src/types/challenge.ts Normal file
View File

@@ -0,0 +1,47 @@
export type WorkbenchType =
| 'math-input'
| 'multiple-choice'
| 'code-editor'
| 'circuit-builder'
| 'physics-sim'
| 'signal-playground'
| 'graph-plotter';
export interface MathInputContent {
type: 'math-input';
problem: string;
answer: {
type: 'numeric' | 'expression';
value: number | string;
tolerance?: number;
};
}
export interface MultipleChoiceContent {
type: 'multiple-choice';
question: string;
options: string[];
correctIndex: number;
}
export type ChallengeContent = MathInputContent | MultipleChoiceContent;
export interface Challenge {
id: string;
nodeId: string;
title: string;
description: string;
difficulty: 1 | 2 | 3 | 4 | 5;
type: WorkbenchType;
hints: string[];
xpReward: number;
content: ChallengeContent;
/** Explanation shown before the challenge to teach the concept */
explanation?: string;
}
export interface VerificationResult {
correct: boolean;
message: string;
xpEarned: number;
}

30
src/types/skill-tree.ts Normal file
View File

@@ -0,0 +1,30 @@
export type Discipline =
| 'mathematics'
| 'logic'
| 'programming'
| 'physics'
| 'electronics'
| 'cryptography'
| 'signal-processing'
| 'statistics';
export interface SkillNode {
id: string;
discipline: Discipline;
branch: string;
title: string;
description: string;
icon: string;
position: { x: number; y: number };
prerequisites: string[];
challenges: string[];
difficulty: 1 | 2 | 3 | 4 | 5;
}
export interface SkillEdge {
from: string;
to: string;
type: 'hard' | 'soft';
}
export type NodeStatus = 'locked' | 'available' | 'in-progress' | 'completed' | 'mastered';

53
src/types/user.ts Normal file
View File

@@ -0,0 +1,53 @@
export interface ChallengeCompletion {
bestScore: number;
completedAt: string;
timeSpent: number;
attempts: number;
}
export interface UserProgress {
completedChallenges: Record<string, ChallengeCompletion>;
unlockedNodes: string[];
totalXP: number;
level: number;
currentStreak: number;
longestStreak: number;
lastActiveDate: string;
achievements: string[];
}
export interface Achievement {
id: string;
title: string;
description: string;
icon: string;
condition: (progress: UserProgress) => boolean;
}
export function xpForLevel(level: number): number {
return Math.floor(100 * Math.pow(1.5, level - 1));
}
export function levelFromXP(totalXP: number): number {
let level = 1;
let xpNeeded = 100;
let accumulated = 0;
while (accumulated + xpNeeded <= totalXP) {
accumulated += xpNeeded;
level++;
xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
}
return level;
}
export function xpProgressInLevel(totalXP: number): { current: number; needed: number } {
let level = 1;
let xpNeeded = 100;
let accumulated = 0;
while (accumulated + xpNeeded <= totalXP) {
accumulated += xpNeeded;
level++;
xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
}
return { current: totalXP - accumulated, needed: xpNeeded };
}