Volver al curso

JavaScript Desde Cero: Tu Primer Lenguaje de Programación

leccion
19 / 22
beginner
8 horas
Proyectos Prácticos

Proyecto 1: Calculadora Interactiva Completa

Lectura
50 min~9 min lectura

Proyecto 1: Calculadora Interactiva Completa

Objetivos de aprendizaje

Al finalizar este proyecto serás capaz de:

  • Construir una calculadora funcional con interfaz visual profesional
  • Aplicar manejo de estado, eventos y DOM manipulation en un proyecto real
  • Implementar lógica matemática con manejo de errores (división por cero, overflow)
  • Usar event delegation para manejar todos los botones eficientemente
  • Agregar funcionalidades avanzadas como historial, teclado y temas

Descripción del proyecto

Vamos a construir una calculadora completa que funcione tanto con clicks como con el teclado. No es la típica calculadora básica de tutorial — esta tendrá funcionalidades reales.

Funcionalidades requeridas

  1. Operaciones básicas: suma, resta, multiplicación, división
  2. Operaciones adicionales: porcentaje, cambio de signo (+/-)
  3. Botón CE (Clear Entry) y C (Clear All)
  4. Punto decimal con validación (no permitir dos puntos)
  5. Soporte de teclado completo
  6. Historial de operaciones
  7. Animaciones en botones
  8. Diseño responsive

Paso 1: La estructura HTML
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Calculadora JavaScript</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="calculator-container">
    <div class="calculator">
      <!-- Display -->
      <div class="display">
        <div class="display-history" id="displayHistory"></div>
        <div class="display-current" id="displayCurrent">0</div>
      </div>
      
      <!-- Botones -->
      <div class="buttons" id="buttons">
        <button class="btn btn-function" data-action="clear">C</button>
        <button class="btn btn-function" data-action="clear-entry">CE</button>
        <button class="btn btn-function" data-action="percent">%</button>
        <button class="btn btn-operator" data-action="divide">/</button>
        
        <button class="btn btn-number" data-number="7">7</button>
        <button class="btn btn-number" data-number="8">8</button>
        <button class="btn btn-number" data-number="9">9</button>
        <button class="btn btn-operator" data-action="multiply">x</button>
        
        <button class="btn btn-number" data-number="4">4</button>
        <button class="btn btn-number" data-number="5">5</button>
        <button class="btn btn-number" data-number="6">6</button>
        <button class="btn btn-operator" data-action="subtract">-</button>
        
        <button class="btn btn-number" data-number="1">1</button>
        <button class="btn btn-number" data-number="2">2</button>
        <button class="btn btn-number" data-number="3">3</button>
        <button class="btn btn-operator" data-action="add">+</button>
        
        <button class="btn btn-number" data-action="toggle-sign">+/-</button>
        <button class="btn btn-number" data-number="0">0</button>
        <button class="btn btn-number" data-action="decimal">.</button>
        <button class="btn btn-equals" data-action="equals">=</button>
      </div>
    </div>
    
    <!-- Historial -->
    <div class="history" id="history">
      <h3>Historial</h3>
      <div class="history-list" id="historyList"></div>
      <button class="btn-clear-history" id="clearHistory">Limpiar historial</button>
    </div>
  </div>
  
  <script src="calculator.js"></script>
</body>
</html>

Paso 2: Estilos CSS
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  color: white;
}

.calculator-container {
  display: flex;
  gap: 20px;
  align-items: flex-start;
}

.calculator {
  background: rgba(255,255,255,0.05);
  backdrop-filter: blur(20px);
  border-radius: 24px;
  padding: 24px;
  width: 320px;
  box-shadow: 0 25px 50px rgba(0,0,0,0.4);
  border: 1px solid rgba(255,255,255,0.1);
}

.display {
  background: rgba(0,0,0,0.3);
  border-radius: 16px;
  padding: 20px;
  margin-bottom: 20px;
  min-height: 100px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-end;
}

.display-history {
  font-size: 0.9rem;
  color: rgba(255,255,255,0.5);
  min-height: 24px;
  word-break: break-all;
}

.display-current {
  font-size: 2.5rem;
  font-weight: 300;
  word-break: break-all;
  max-width: 100%;
}

.buttons {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
}

.btn {
  padding: 18px;
  border: none;
  border-radius: 12px;
  font-size: 1.2rem;
  cursor: pointer;
  transition: all 0.15s ease;
  font-weight: 500;
}

.btn:active { transform: scale(0.95); }

.btn-number {
  background: rgba(255,255,255,0.1);
  color: white;
}
.btn-number:hover { background: rgba(255,255,255,0.2); }

.btn-function {
  background: rgba(255,255,255,0.15);
  color: #a5b4fc;
}
.btn-function:hover { background: rgba(255,255,255,0.25); }

.btn-operator {
  background: rgba(99, 102, 241, 0.3);
  color: #818cf8;
}
.btn-operator:hover { background: rgba(99, 102, 241, 0.5); }

.btn-equals {
  background: #6366f1;
  color: white;
}
.btn-equals:hover { background: #4f46e5; }

.history {
  background: rgba(255,255,255,0.05);
  backdrop-filter: blur(20px);
  border-radius: 24px;
  padding: 20px;
  width: 250px;
  max-height: 500px;
  overflow-y: auto;
  border: 1px solid rgba(255,255,255,0.1);
}

.history h3 { margin-bottom: 12px; color: #a5b4fc; }

.history-item {
  padding: 8px 0;
  border-bottom: 1px solid rgba(255,255,255,0.05);
  font-size: 0.85rem;
  color: rgba(255,255,255,0.7);
  cursor: pointer;
}
.history-item:hover { color: white; }
.history-item .result { color: #6366f1; font-weight: bold; }

.btn-clear-history {
  margin-top: 12px;
  width: 100%;
  padding: 8px;
  background: rgba(239, 68, 68, 0.2);
  color: #fca5a5;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

@media (max-width: 700px) {
  .calculator-container { flex-direction: column; align-items: center; }
  .history { width: 320px; }
}

Paso 3: La lógica JavaScript
// ============================================
// CALCULADORA INTERACTIVA
// Proyecto del Módulo 5: Proyectos Prácticos
// ============================================

// Estado de la calculadora
const estado = {
  displayActual: "0",
  displayHistorial: "",
  primerOperando: null,
  operador: null,
  esperandoSegundoOperando: false,
  historial: []
};

// Elementos del DOM
const displayCurrent = document.getElementById("displayCurrent");
const displayHistory = document.getElementById("displayHistory");
const buttonsContainer = document.getElementById("buttons");
const historyList = document.getElementById("historyList");
const clearHistoryBtn = document.getElementById("clearHistory");

// Operaciones
const operaciones = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => {
    if (b === 0) throw new Error("No se puede dividir por cero");
    return a / b;
  }
};

const simbolos = {
  add: "+",
  subtract: "-",
  multiply: "x",
  divide: "/"
};

// Funciones principales
function ingresarNumero(numero) {
  if (estado.esperandoSegundoOperando) {
    estado.displayActual = numero;
    estado.esperandoSegundoOperando = false;
  } else {
    // No permitir ceros al inicio (excepto "0.")
    if (estado.displayActual === "0" && numero !== ".") {
      estado.displayActual = numero;
    } else {
      // Limitar longitud
      if (estado.displayActual.length >= 15) return;
      estado.displayActual += numero;
    }
  }
  actualizarDisplay();
}

function ingresarDecimal() {
  if (estado.esperandoSegundoOperando) {
    estado.displayActual = "0.";
    estado.esperandoSegundoOperando = false;
    actualizarDisplay();
    return;
  }
  // No permitir dos puntos decimales
  if (estado.displayActual.includes(".")) return;
  estado.displayActual += ".";
  actualizarDisplay();
}

function seleccionarOperador(operador) {
  const valorActual = parseFloat(estado.displayActual);
  
  if (estado.primerOperando !== null && !estado.esperandoSegundoOperando) {
    // Ya hay un operador pendiente — calcular primero
    try {
      const resultado = operaciones[estado.operador](estado.primerOperando, valorActual);
      estado.displayActual = formatearNumero(resultado);
      estado.primerOperando = resultado;
    } catch (error) {
      estado.displayActual = "Error";
      estado.primerOperando = null;
      estado.operador = null;
      estado.esperandoSegundoOperando = false;
      actualizarDisplay();
      return;
    }
  } else {
    estado.primerOperando = valorActual;
  }
  
  estado.operador = operador;
  estado.esperandoSegundoOperando = true;
  estado.displayHistorial = `${formatearNumero(estado.primerOperando)} ${simbolos[operador]}`;
  actualizarDisplay();
}

function calcular() {
  if (estado.operador === null || estado.primerOperando === null) return;
  
  const segundoOperando = parseFloat(estado.displayActual);
  const expresion = `${formatearNumero(estado.primerOperando)} ${simbolos[estado.operador]} ${formatearNumero(segundoOperando)}`;
  
  try {
    const resultado = operaciones[estado.operador](estado.primerOperando, segundoOperando);
    const resultadoFormateado = formatearNumero(resultado);
    
    // Guardar en historial
    agregarAlHistorial(expresion, resultadoFormateado);
    
    estado.displayActual = resultadoFormateado;
    estado.displayHistorial = `${expresion} =`;
    estado.primerOperando = resultado;
    estado.operador = null;
    estado.esperandoSegundoOperando = true;
  } catch (error) {
    estado.displayActual = "Error";
    estado.displayHistorial = expresion;
    resetearEstado();
  }
  
  actualizarDisplay();
}

function porcentaje() {
  const valor = parseFloat(estado.displayActual);
  if (estado.primerOperando !== null) {
    // X% de primerOperando
    estado.displayActual = formatearNumero(estado.primerOperando * (valor / 100));
  } else {
    estado.displayActual = formatearNumero(valor / 100);
  }
  actualizarDisplay();
}

function cambiarSigno() {
  const valor = parseFloat(estado.displayActual);
  estado.displayActual = formatearNumero(valor * -1);
  actualizarDisplay();
}

function limpiarTodo() {
  estado.displayActual = "0";
  estado.displayHistorial = "";
  estado.primerOperando = null;
  estado.operador = null;
  estado.esperandoSegundoOperando = false;
  actualizarDisplay();
}

function limpiarEntrada() {
  estado.displayActual = "0";
  actualizarDisplay();
}

// Funciones auxiliares
function formatearNumero(num) {
  if (typeof num !== "number" || !isFinite(num)) return "Error";
  // Limitar decimales y quitar ceros innecesarios
  const resultado = parseFloat(num.toPrecision(12));
  return resultado.toString();
}

function actualizarDisplay() {
  displayCurrent.textContent = estado.displayActual;
  displayHistory.textContent = estado.displayHistorial;
  
  // Ajustar tamaño de fuente si el número es largo
  const longitud = estado.displayActual.length;
  if (longitud > 12) {
    displayCurrent.style.fontSize = "1.5rem";
  } else if (longitud > 9) {
    displayCurrent.style.fontSize = "2rem";
  } else {
    displayCurrent.style.fontSize = "2.5rem";
  }
}

function resetearEstado() {
  estado.primerOperando = null;
  estado.operador = null;
  estado.esperandoSegundoOperando = false;
}

function agregarAlHistorial(expresion, resultado) {
  estado.historial.unshift({ expresion, resultado, fecha: new Date() });
  if (estado.historial.length > 20) estado.historial.pop();
  renderizarHistorial();
}

function renderizarHistorial() {
  historyList.innerHTML = estado.historial.map(item => `
    <div class="history-item" data-resultado="${item.resultado}">
      ${item.expresion} = <span class="result">${item.resultado}</span>
    </div>
  `).join("");
}

// Event Delegation para botones
buttonsContainer.addEventListener("click", (e) => {
  const btn = e.target.closest(".btn");
  if (!btn) return;
  
  // Animación de click
  btn.style.transform = "scale(0.95)";
  setTimeout(() => btn.style.transform = "", 100);
  
  const numero = btn.dataset.number;
  const accion = btn.dataset.action;
  
  if (numero !== undefined) {
    ingresarNumero(numero);
    return;
  }
  
  switch (accion) {
    case "add":
    case "subtract":
    case "multiply":
    case "divide":
      seleccionarOperador(accion);
      break;
    case "equals": calcular(); break;
    case "clear": limpiarTodo(); break;
    case "clear-entry": limpiarEntrada(); break;
    case "decimal": ingresarDecimal(); break;
    case "percent": porcentaje(); break;
    case "toggle-sign": cambiarSigno(); break;
  }
});

// Soporte de teclado
const teclaToAccion = {
  "0": () => ingresarNumero("0"),
  "1": () => ingresarNumero("1"),
  "2": () => ingresarNumero("2"),
  "3": () => ingresarNumero("3"),
  "4": () => ingresarNumero("4"),
  "5": () => ingresarNumero("5"),
  "6": () => ingresarNumero("6"),
  "7": () => ingresarNumero("7"),
  "8": () => ingresarNumero("8"),
  "9": () => ingresarNumero("9"),
  ".":  () => ingresarDecimal(),
  "+": () => seleccionarOperador("add"),
  "-": () => seleccionarOperador("subtract"),
  "*": () => seleccionarOperador("multiply"),
  "/": () => seleccionarOperador("divide"),
  "Enter": () => calcular(),
  "=": () => calcular(),
  "Escape": () => limpiarTodo(),
  "Backspace": () => {
    if (estado.displayActual.length > 1) {
      estado.displayActual = estado.displayActual.slice(0, -1);
    } else {
      estado.displayActual = "0";
    }
    actualizarDisplay();
  },
  "%": () => porcentaje(),
};

document.addEventListener("keydown", (e) => {
  const accion = teclaToAccion[e.key];
  if (accion) {
    e.preventDefault();
    accion();
  }
});

// Historial: click para reutilizar resultado
historyList.addEventListener("click", (e) => {
  const item = e.target.closest(".history-item");
  if (!item) return;
  estado.displayActual = item.dataset.resultado;
  actualizarDisplay();
});

clearHistoryBtn.addEventListener("click", () => {
  estado.historial = [];
  renderizarHistorial();
});

console.log("Calculadora cargada. Usá el teclado o los botones.");

Conceptos aplicados

Este proyecto utiliza:

Concepto Dónde se usa
Variables y constantes Estado de la calculadora, configuración
Objetos Estado, operaciones, símbolos, mapeo de teclas
Funciones Cada operación es una función pura
Condicionales Validaciones (decimal, overflow, división por cero)
Switch Mapeo de acciones de botones
DOM manipulation Actualizar display, renderizar historial
Event delegation Un solo listener para todos los botones
Eventos de teclado Soporte completo de teclado
Closures Estado encapsulado en el módulo
Template literals Renderizado dinámico del historial
Error handling try/catch para división por cero
Arrays Historial de operaciones

Desafíos extra
  1. Memoria (M+, M-, MR, MC): Agregá botones de memoria
  2. Modo científico: raíz cuadrada, potencia, seno, coseno
  3. Temas (claro/oscuro): Toggle entre temas con localStorage
  4. Historial persistente: Guardá el historial en localStorage
  5. Animaciones: Transiciones CSS suaves en el display
  6. PWA: Convertilo en una Progressive Web App instalable

💡 Concepto Clave

Revisemos los puntos más importantes de esta lección antes de continuar.

Quiz de autoevaluación

1. ¿Por qué usamos event delegation en los botones?
a) Porque es más lento pero más seguro
b) Para tener un solo listener en vez de 20, y que funcione con elementos dinámicos
c) Porque addEventListener no funciona en botones
d) Es obligatorio en JavaScript

2. ¿Por qué necesitamos el estado esperandoSegundoOperando?
a) Para animaciones
b) Para saber si el siguiente número debe reemplazar o concatenarse al display
c) Para manejar errores
d) No es necesario

3. ¿Qué pasa si no manejamos la división por cero?
a) JavaScript devuelve 0
b) JavaScript devuelve Infinity (no error)
c) El navegador se cierra
d) Da un error de sintaxis

Respuestas: 1-b, 2-b, 3-b

🧠 Pon a prueba tu conocimiento
¿Cuál es el aspecto más importante que aprendiste en esta lección?
  • Comprendo el concepto principal y puedo explicarlo con mis palabras
  • Entiendo cómo aplicarlo en mi situación específica
  • Necesito repasar algunas partes antes de continuar
  • Quiero ver más ejemplos prácticos del tema
✅ ¡Excelente! Continúa con la siguiente lección para profundizar más.