En esta página

Proyecto final: TaskBoard completo

25 min lectura TextoCap. 5 — Producción

Proyecto final: TaskBoard

En esta leccion final, integraremos todo lo aprendido para construir un TaskBoard completo estilo Kanban. Esta aplicación demuestra los conceptos fundamentales de Angular 21.

Conceptos integrados

Este proyecto utiliza:

Concepto Leccion Uso en el proyecto
Componentes L2 TaskBoard, TaskColumn, TaskCard
Templates L3 @if, @for, @switch con control flow
Signals L4 Estado de tareas, filtros
Computed L4 Estadisticas, filtrado por columna
Effects L5 Persistencia en localStorage
Inputs/Outputs L6 Comunicación entre componentes
Servicios L7 TaskService centralizado
Routing L8 Navegación entre vistas
Formularios L9 Creación de tareas
Pipes L12 Formateo de fechas y texto

Arquitectura del proyecto

task-board/
  task.service.ts          # Estado global con signals
  task-board.ts            # Componente contenedor principal
  task-board.html          # Template del board
  task-column.ts           # Columna del kanban (pendiente/progreso/completada)
  task-card.ts             # Tarjeta individual de tarea

TaskService: estado centralizado

El servicio gestiona todo el estado de las tareas. Usa signals para reactividad y computed para derivar las columnas:

// Estado privado
private readonly _tareas = signal<Tarea[]>([]);

// Lecturas publicas
readonly tareas = this._tareas.asReadonly();
readonly pendientes = computed(() =>
  this._tareas().filter(t => t.estado === 'pendiente')
);

El patrón clave es: estado privado, lectura pública, mutacion via métodos.

TaskColumn: componente presentacional

Cada columna recibe su lista de tareas via input y emite eventos al padre:

@Component({
  selector: 'app-task-column',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [TaskCard],
  template: `
    <div class="columna">
      <h2>{{ título() }} ({{ tareas().length }})</h2>
      @for (tarea of tareas(); track tarea.id) {
        <app-task-card
          [tarea]="tarea"
          (mover)="mover.emit($event)"
          (eliminar)="eliminar.emit($event)"
        />
      } @empty {
        <p class="vacio">Sin tareas</p>
      }
    </div>
  `,
})
export class TaskColumn {
  readonly título = input.required<string>();
  readonly tareas = input.required<Tarea[]>();
  readonly mover = output<{ id: number; estado: Tarea['estado'] }>();
  readonly eliminar = output<number>();
}

TaskCard: tarjeta individual

Cada tarjeta muestra la información de una tarea con acciones:

@Component({
  selector: 'app-task-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="card" [class]="'prioridad-' + tarea().prioridad">
      <h3>{{ tarea().título }}</h3>
      @if (tarea().descripción) {
        <p>{{ tarea().descripción }}</p>
      }
      <div class="acciones">
        @switch (tarea().estado) {
          @case ('pendiente') {
            <button (click)="mover.emit({ id: tarea().id, estado: 'progreso' })">
              Iniciar
            </button>
          }
          @case ('progreso') {
            <button (click)="mover.emit({ id: tarea().id, estado: 'completada' })">
              Completar
            </button>
          }
        }
        <button (click)="eliminar.emit(tarea().id)">Eliminar</button>
      </div>
    </article>
  `,
})
export class TaskCard {
  readonly tarea = input.required<Tarea>();
  readonly mover = output<{ id: number; estado: Tarea['estado'] }>();
  readonly eliminar = output<number>();
}

Persistencia con effect

Agrega persistencia local con un effect en el servicio:

constructor() {
  // Cargar tareas guardadas
  const guardadas = localStorage.getItem('tareas');
  if (guardadas) {
    this._tareas.set(JSON.parse(guardadas));
  }

  // Guardar automaticamente cada vez que cambian
  effect(() => {
    localStorage.setItem('tareas', JSON.stringify(this._tareas()));
  });
}

Desafios para seguir aprendiendo

  1. Drag and drop — Implementa arrastrar tarjetas entre columnas usando CDK DragDrop
  2. Filtros — Agrega filtrado por prioridad usando un signal + computed
  3. Busqueda — Usa un FormControl con debounceTime para buscar tareas
  4. API real — Conecta con Firebase Firestore usando HttpClient o @angular/fire
  5. Tests — Escribe tests unitarios para TaskService con Vitest
  6. Routing — Agrega una vista de detalle de tarea con parametros de ruta

Felicidades

Has completado el curso Angular para Desarrolladores. Ahora tienes las bases para construir aplicaciones Angular 21 modernas con:

  • Componentes standalone y signals
  • Control flow nativo en templates
  • Servicios con inyección de dependencias
  • Formularios reactivos con validación
  • HttpClient y RxJS
  • SSR y pre-rendering
  • Testing básico

Sigue practicando construyendo proyectos reales. Consulta la documentación oficial en angular.dev para profundizar en cada tema.

Extiende el proyecto
Desafios adicionales: agrega persistencia con localStorage usando un effect(), implementa drag-and-drop entre columnas, agrega filtros por prioridad con computed(), o conecta con una API real usando HttpClient.
Estructura recomendada
Organiza el proyecto con feature folders: task-board/ contiene el componente principal, task-column/ el componente de columna, task-card/ la tarjeta individual, y task.service.ts la lógica de estado.
// === task.service.ts ===
import { Injectable, signal, computed } from '@angular/core';

interface Tarea {
  id: number;
  título: string;
  descripción: string;
  estado: 'pendiente' | 'progreso' | 'completada';
  prioridad: 'alta' | 'media' | 'baja';
  creada: Date;
}

@Injectable({ providedIn: 'root' })
export class TaskService {
  private readonly _tareas = signal<Tarea[]>([]);
  readonly tareas = this._tareas.asReadonly();

  readonly pendientes = computed(
    () => this._tareas().filter(t => t.estado === 'pendiente')
  );
  readonly enProgreso = computed(
    () => this._tareas().filter(t => t.estado === 'progreso')
  );
  readonly completadas = computed(
    () => this._tareas().filter(t => t.estado === 'completada')
  );
  readonly totalTareas = computed(() => this._tareas().length);
  readonly porcentajeCompletado = computed(() => {
    const total = this._tareas().length;
    if (total === 0) return 0;
    return Math.round(
      (this._tareas().filter(t => t.estado === 'completada').length / total) * 100
    );
  });

  agregar(título: string, descripción: string, prioridad: Tarea['prioridad']): void {
    this._tareas.update(lista => [
      ...lista,
      {
        id: Date.now(),
        título,
        descripción,
        estado: 'pendiente',
        prioridad,
        creada: new Date(),
      },
    ]);
  }

  moverEstado(id: number, estado: Tarea['estado']): void {
    this._tareas.update(lista =>
      lista.map(t => t.id === id ? { ...t, estado } : t)
    );
  }

  eliminar(id: number): void {
    this._tareas.update(lista => lista.filter(t => t.id !== id));
  }
}

// === task-board.ts ===
import {
  Component, inject, signal,
  ChangeDetectionStrategy,
} from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { TaskService } from './task.service';
import { TaskColumn } from './task-column';

@Component({
  selector: 'app-task-board',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule, TaskColumn],
  templateUrl: './task-board.html',
})
export class TaskBoard {
  readonly taskService = inject(TaskService);
  private readonly fb = inject(FormBuilder);

  readonly mostrarFormulario = signal(false);

  readonly formulario = this.fb.nonNullable.group({
    título: ['', [Validators.required, Validators.minLength(3)]],
    descripción: [''],
    prioridad: ['media' as const, Validators.required],
  });

  agregarTarea(): void {
    if (this.formulario.invalid) return;
    const { título, descripción, prioridad } = this.formulario.getRawValue();
    this.taskService.agregar(título, descripción, prioridad);
    this.formulario.reset({ título: '', descripción: '', prioridad: 'media' });
    this.mostrarFormulario.set(false);
  }
}