learning-front

Nivel 2 · JavaScript: fundamentos del lenguaje

Proyecto: el Team Builder cobra vida con JavaScript

Mega-tarea de cierre del Nivel 2: partiendo de la home del Nivel 1 (HTML semántico + CSS responsive), añadir un app.js que la haga interactiva. Sin concepto nuevo: es integrar todo lo del nivel en un proyecto real.


El Nivel 2 ha cubierto variables, comparaciones, template literals, condicionales, bucles, funciones, arrays, objetos, métodos de array y el DOM. Son nueve capítulos de fundamentos. Este es el décimo: no hay concepto nuevo. La pregunta es si puedes usar todo eso junto para resolver un problema real de principio a fin.

Qué construyes#

Partes de la home del Team Builder que maquetaste en el Nivel 1: el HTML semántico, el CSS responsive y las tarjetas ya dibujadas. Esa base sigue intacta.

No tienes que traer tu propio trabajo del Nivel 1: el ejercicio ya incluye el HTML y el CSS de partida listos (adaptados para recibir contenido dinámico). Aunque no hicieras el proyecto anterior, aquí arrancas con la maqueta puesta y solo añades el JS.

Lo que añades en este proyecto es un app.js que le da vida:

  • Pintar las cartas desde datos. En vez de tener ocho <article> hardcodeados en el HTML, el JS lee el array de héroes y genera los nodos del DOM.
  • Filtrar por rol. Cuatro botones (Todos / Tanque / Daño / Apoyo) que, al pulsarse, muestran solo los héroes del rol elegido y marcan el botón activo.
  • Buscar por nombre. Un <input> que filtra en tiempo real mientras escribes, combinado con el filtro de rol.
  • Calcular y mostrar el winrate. victorias / partidas × 100, redondeado, en porcentaje.
  • Estado vacío. Si los filtros combinados no devuelven héroes, aparece un aviso.

El HTML y el CSS siguen siendo tu responsabilidad: los criterios de aceptación exigen que sigan semánticos y responsive en los tres tiers.

Por qué importa construir sobre lo anterior#

En un proyecto real nunca empiezas de cero. Te incorporas a una base existente, o añades una capa sobre algo que ya funciona. La disciplina de no romper lo que ya estaba (HTML semántico, foco accesible, grid responsive a 375px) es tan parte del trabajo como añadir las funcionalidades nuevas.

El tier «excelente» no solo exige que el JS funcione: exige que la página siga accesible con contenido generado dinámicamente y que respete prefers-reduced-motion, igual que pedía el proyecto del Nivel 1.

Cómo se ataca#

El orden importa. Empieza por lo más pequeño que demuestre que funciona:

  1. Coge las referencias al DOM que vas a necesitar (getElementById o querySelector).
  2. Define el dataset en el propio fichero (array de objetos con nombre, siglas, rol, partidas, victorias).
  3. Escribe la función de render: recibe un array y pinta las tarjetas. Comprueba que se ven al abrir el fichero antes de añadir eventos.
  4. Añade los botones de filtro (puedes crearlos con createElement o escribirlos en el HTML; cualquiera está bien para el tier «ok»).
  5. Conecta los eventos de los botones y del input de búsqueda. Para la búsqueda por nombre sin distinguir mayúsculas, usa .toLowerCase() y .includes() que viste en el capítulo de template literals: nombre.toLowerCase().includes(texto.toLowerCase()).
  6. Verifica a 375px: arrastra el borde del navegador o usa DevTools. Los filtros y la búsqueda deben funcionar igual de bien en móvil.

No intentes hacer todo a la vez. Un paso a la vez, comprueba que funciona, y avanza.

Una nota sobre DocumentFragment, que aparece en los criterios del tier «excelente»: es una técnica de optimización del DOM que no se ha enseñado en un capítulo propio y no es requisito para aprobar el proyecto. La descubrirás leyendo la solución «excelente», donde va comentada. Considérala un premio para quien quiera ir más allá, no un obstáculo de paso.

Comprueba lo que sabes#

Pregunta 1 de 5

El grid de héroes lo pinta el JS cada vez que cambia un filtro. ¿Por qué es mejor calcularlo con funciones puras en lugar de lógica mezclada?

Tu turno#

Este ejercicio se hace en local, en tu editor: es un proyecto completo que conviene vivir como tal, con tus ficheros, tu editor y tu navegador.

Clona o descarga la carpeta, abre app.js y sigue los TODO. Cuando termines, despliega las soluciones y compara. Fíjate en cómo el «ok» mezcla lógica y en cómo el «excelente» la separa sin complicar más el código.

Ejercicio · hazlo en local

Dale vida al Team Builder con JavaScript

El HTML y el CSS ya están (los construiste en el Nivel 1, adaptados para recibir contenido dinámico). Tu trabajo es app.js: leer el dataset de héroes, pintarlos en el grid, hacer que los botones de filtro funcionen y conectar la búsqueda por nombre. Sin módulos import/export ni fetch: los datos van como array literal en el propio fichero.

Paso 1: Que funcione

  • Las ocho cartas se ven al cargar la página (pintadas desde el array, no hardcodeadas en el HTML).
  • Los botones Todos / Tanque / Daño / Apoyo filtran correctamente y el botón activo se marca con la clase "activo".
  • El input de búsqueda filtra en tiempo real por nombre (sin distinguir mayúsculas).
  • Cuando no hay resultados aparece el párrafo de aviso; cuando sí los hay, desaparece.
  • El HTML del Nivel 1 sigue semántico y el CSS sigue responsive (la página no se rompe a ningún ancho).
  • Si usas innerHTML += dentro del bucle de render, funciona pero reprocesa todo el DOM en cada vuelta; el tier «mejor» lo resuelve con createElement y DocumentFragment.

Cómo hacerlo en local

Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el index.html en tu navegador. Toda tu solución va en solucion.js.

git clone <repo>
cd exercises/nivel-2/proyecto-team-builder-js
# abre index.html en el navegador y edita solucion.js
Ver soluciones
// SOLUCIÓN OK — funciona: pinta los héroes, los filtros responden,
// la búsqueda filtra por nombre.
// El HTML/CSS del Nivel 1 sigue intacto: la página se ve bien y es responsiva.
//
// Qué tiene de mejorable:
//   - innerHTML con concatenación de strings: si un nombre llevara "<" o ">",
//     el navegador lo interpretaría como HTML y podría romper la página.
//   - Un listener por tarjeta (bucle for) en lugar de delegación: si hay muchas
//     cartas, hay muchos handlers en memoria.
//   - La lógica de filtrado, render y eventos está mezclada: cuesta seguirla.
//   - El winrate se recalcula cada vez que se repinta en lugar de calcularse una sola vez.

// ── Referencias al DOM ────────────────────────────────────────────────────
// el <ul> donde van las tarjetas
const grid = document.getElementById("hero-grid");
// el <nav> con los botones de rol
const navFiltros = document.getElementById("filtros");
// el <input type="search">
const inputBusqueda = document.getElementById("busqueda");
// el aviso de "sin resultados"
const parrafoVacio = document.getElementById("resultado-vacio");

// ── Dataset de héroes ─────────────────────────────────────────────────────
const heroes = [
  { nombre: "Tracer", siglas: "TR", rol: "Daño", partidas: 120, victorias: 78 },
  {
    nombre: "Reinhardt",
    siglas: "RE",
    rol: "Tanque",
    partidas: 90,
    victorias: 51,
  },
  {
    nombre: "Mercy",
    siglas: "ME",
    rol: "Apoyo",
    partidas: 200,
    victorias: 130,
  },
  { nombre: "Genji", siglas: "GE", rol: "Daño", partidas: 150, victorias: 72 },
  { nombre: "D.Va", siglas: "DV", rol: "Tanque", partidas: 140, victorias: 84 },
  { nombre: "Ana", siglas: "AN", rol: "Apoyo", partidas: 95, victorias: 57 },
  {
    nombre: "Zenyatta",
    siglas: "ZE",
    rol: "Apoyo",
    partidas: 80,
    victorias: 44,
  },
  { nombre: "Pharah", siglas: "PH", rol: "Daño", partidas: 130, victorias: 71 },
];

// ── Estado de la interfaz ─────────────────────────────────────────────────
// el rol del botón que está marcado como activo
let rolActivo = "todos";
// el texto que hay en el input en este momento
let textoBusqueda = "";

// ── Botones de filtro ─────────────────────────────────────────────────────
// los cuatro filtros posibles
const roles = ["todos", "Tanque", "Daño", "Apoyo"];

// recorremos los roles con un bucle
for (let i = 0; i < roles.length; i++) {
  // nombre del rol en esta vuelta
  const rol = roles[i];
  // "todos" se muestra como "Todos"
  const etiqueta = rol === "todos" ? "Todos" : rol;
  // innerHTML crea el botón como string HTML; es rápido pero mezcla JS y marcado
  navFiltros.innerHTML += `<button class="filtro${rol === "todos" ? " activo" : ""}" data-rol="${rol}">${etiqueta}</button>`;
}

// ── Función de render ─────────────────────────────────────────────────────
// recibe el array ya filtrado
function pintarHeroes(lista) {
  // borra las tarjetas anteriores
  grid.innerHTML = "";

  // si no hay resultados...
  if (lista.length === 0) {
    // mostramos el aviso de vacío
    parrafoVacio.hidden = false;
    // y salimos: no hay nada más que pintar
    return;
  }
  // si sí hay resultados, ocultamos el aviso
  parrafoVacio.hidden = true;

  // recorremos el array de héroes a pintar
  for (let i = 0; i < lista.length; i++) {
    // héroe en esta vuelta
    const h = lista[i];
    // calculamos el % (0.65 → 65)
    const winrate = Math.round((h.victorias / h.partidas) * 100);

    // Construimos la tarjeta como un string HTML con template literal.
    // Ojo: si h.nombre contuviera "<script>", esto sería un problema de seguridad.
    const html = `
      <li>
        <article class="hero-card">
          <div class="hero-portrait" aria-hidden="true">${h.siglas}</div>
          <h3 class="hero-name">${h.nombre}</h3>
          <p class="hero-role">${h.rol}</p>
          <dl class="hero-stats">
            <div class="stat"><dt>Partidas</dt><dd>${h.partidas}</dd></div>
            <div class="stat"><dt>Victorias</dt><dd>${h.victorias}</dd></div>
            <div class="stat"><dt>Winrate</dt><dd>${winrate}%</dd></div>
          </dl>
        </article>
      </li>
    `;
    // añadimos la tarjeta al grid
    grid.innerHTML += html;
  }

  // Añadimos el listener de selección DESPUÉS de pintar, porque innerHTML renueva los nodos
  // cogemos todas las tarjetas recién pintadas
  const tarjetas = grid.querySelectorAll(".hero-card");
  // un listener por tarjeta
  for (let j = 0; j < tarjetas.length; j++) {
    // al hacer clic en esta tarjeta...
    tarjetas[j].addEventListener("click", function () {
      // alterna la clase seleccionado
      this.classList.toggle("seleccionado");
    });
  }
}

// ── Función que aplica filtros ────────────────────────────────────────────
// combina rol activo y texto de búsqueda
function aplicarFiltros() {
  // empezamos con todos los héroes
  let resultado = heroes;

  // si el filtro no es "Todos"...
  if (rolActivo !== "todos") {
    // filter devuelve solo los del rol activo
    resultado = resultado.filter(function (h) {
      return h.rol === rolActivo;
    });
  }

  // si hay texto en el buscador...
  if (textoBusqueda !== "") {
    // pasamos a minúsculas para comparar sin distinción
    const texto = textoBusqueda.toLowerCase();
    // filter con los que incluyen el texto
    resultado = resultado.filter(function (h) {
      return h.nombre.toLowerCase().includes(texto);
    });
  }

  // pintamos el resultado filtrado
  pintarHeroes(resultado);
}

// ── Eventos de filtro ─────────────────────────────────────────────────────
// escuchamos todos los clics en el nav
navFiltros.addEventListener("click", function (e) {
  // e.target es el elemento pulsado
  const boton = e.target;
  // ignoramos clics fuera de los botones
  if (!boton.classList.contains("filtro")) return;

  // Marcamos el botón pulsado como activo y quitamos la clase a los demás
  // todos los botones del nav
  const botones = navFiltros.querySelectorAll(".filtro");
  // recorremos para quitar la clase activo
  for (let k = 0; k < botones.length; k++) {
    botones[k].classList.remove("activo");
  }
  // ponemos activo solo en el pulsado
  boton.classList.add("activo");

  // guardamos el rol seleccionado
  rolActivo = boton.dataset.rol;
  // y repintamos con los filtros combinados
  aplicarFiltros();
});

// ── Evento de búsqueda ────────────────────────────────────────────────────
// se dispara en cada tecla
inputBusqueda.addEventListener("input", function () {
  // guardamos el texto actual del input
  textoBusqueda = this.value;
  // y repintamos combinando rol + texto
  aplicarFiltros();
});

// ── Arranque ──────────────────────────────────────────────────────────────
// pintamos todos los héroes al cargar la página
pintarHeroes(heroes);

Por qué este nivel

  • Funciona: las cartas se pintan desde el array, los filtros responden y la búsqueda filtra en tiempo real.
  • Construye las tarjetas con innerHTML y template literals: es rápido de escribir, pero si los datos provinieran del usuario, un valor con < o > podría interpretarse como HTML.
  • El winrate se recalcula en cada render en vez de una sola vez al enriquecer los datos.
  • Un listener por tarjeta (bucle for) en lugar de delegación: si hay muchas cartas, hay muchos handlers en memoria, y las tarjetas pintadas tras un filtro no tienen listener hasta que se repinta.
  • La lógica de filtrado, render y eventos está mezclada: funciona, pero cuesta seguirla y cambiarla.