En esta página
Proyecto final: Sistema de gestión de tareas
Proyecto final: ¡integra todo lo que aprendiste!
¡Felicidades por llegar a la última lección del curso! Este proyecto final integra todos los conceptos vistos a lo largo de las 16 lecciones en una sola aplicación cohesionada: un sistema de gestión de tareas con usuarios, proyectos y estadísticas.
El objetivo no es memorizar cada línea de código, sino ver cómo las piezas encajan: los tipos de la lección 1 con los genéricos de la lección 8, las discriminated unions del narrowing con los utility types, los decoradores con los módulos. TypeScript es un sistema que se refuerza a sí mismo.
Lo que construirás
El sistema incluye:
- Interfaces para
Tarea,UsuarioyProyectocon herencia deEntidadBase. - Discriminated union para el estado de una tarea (
pendiente,en-progreso,completada,cancelada). - Patrón repositorio genérico reutilizable para cualquier entidad.
- Utility types para DTOs de creación y actualización.
- Decoradores de logging para los métodos del servicio.
- Type guards personalizados para filtrar tareas por estado.
- Template literal types para las claves de estadísticas.
- Mapped types para los filtros de búsqueda.
Código completo del proyecto
// ════════════════════════════════════════════════════════════
// TIPOS BASE
// ════════════════════════════════════════════════════════════
type ID = string;
function generarId(): ID {
return Math.random().toString(36).slice(2, 10).toUpperCase();
}
interface EntidadBase {
readonly id: ID;
readonly creadaEn: Date;
actualizadaEn: Date;
}
// ════════════════════════════════════════════════════════════
// DOMINIO: USUARIOS
// ════════════════════════════════════════════════════════════
interface Usuario extends EntidadBase {
nombre: string;
email: string;
rol: "admin" | "miembro" | "observador";
activo: boolean;
}
type CrearUsuarioDto = Omit<Usuario, keyof EntidadBase>;
type ActualizarUsuarioDto = Partial<Pick<Usuario, "nombre" | "email" | "rol" | "activo">>;
// ════════════════════════════════════════════════════════════
// DOMINIO: PROYECTOS
// ════════════════════════════════════════════════════════════
interface Proyecto extends EntidadBase {
nombre: string;
descripcion: string;
responsableId: ID;
miembrosIds: readonly ID[];
archivado: boolean;
}
type CrearProyectoDto = Omit<Proyecto, keyof EntidadBase>;
type ActualizarProyectoDto = Partial<Pick<Proyecto, "nombre" | "descripcion" | "archivado">>;
// ════════════════════════════════════════════════════════════
// DOMINIO: TAREAS — discriminated union para estado
// ════════════════════════════════════════════════════════════
type Prioridad = "baja" | "media" | "alta" | "crítica";
type EstadoTarea =
| { tipo: "pendiente" }
| { tipo: "en-progreso"; iniciadaEn: Date; asignadaA: ID }
| { tipo: "completada"; completadaEn: Date; notas?: string }
| { tipo: "cancelada"; motivoCancelacion: string };
interface Tarea extends EntidadBase {
titulo: string;
descripcion: string;
prioridad: Prioridad;
etiquetas: readonly string[];
proyectoId: ID | null;
estado: EstadoTarea;
}
type CrearTareaDto = Omit<Tarea, keyof EntidadBase>;
type ActualizarTareaDto = Partial<Pick<Tarea, "titulo" | "descripcion" | "prioridad" | "etiquetas" | "proyectoId">>;
// ════════════════════════════════════════════════════════════
// TYPE GUARDS
// ════════════════════════════════════════════════════════════
function esPendiente(tarea: Tarea): tarea is Tarea & { estado: { tipo: "pendiente" } } {
return tarea.estado.tipo === "pendiente";
}
function estaEnProgreso(
tarea: Tarea
): tarea is Tarea & { estado: { tipo: "en-progreso"; iniciadaEn: Date; asignadaA: ID } } {
return tarea.estado.tipo === "en-progreso";
}
function estaCompletada(
tarea: Tarea
): tarea is Tarea & { estado: { tipo: "completada"; completadaEn: Date; notas?: string } } {
return tarea.estado.tipo === "completada";
}
function estaCancelada(
tarea: Tarea
): tarea is Tarea & { estado: { tipo: "cancelada"; motivoCancelacion: string } } {
return tarea.estado.tipo === "cancelada";
}
// ════════════════════════════════════════════════════════════
// REPOSITORIO GENÉRICO
// ════════════════════════════════════════════════════════════
interface Repositorio<T extends EntidadBase, TCrear, TActualizar> {
obtener(id: ID): T | null;
listar(): T[];
crear(datos: TCrear): T;
actualizar(id: ID, cambios: TActualizar): T | null;
eliminar(id: ID): boolean;
}
class RepositorioEnMemoria<T extends EntidadBase, TCrear, TActualizar extends Partial<Omit<T, "id" | "creadaEn">>>
implements Repositorio<T, TCrear, TActualizar> {
protected store: Map<ID, T> = new Map();
obtener(id: ID): T | null {
return this.store.get(id) ?? null;
}
listar(): T[] {
return Array.from(this.store.values());
}
crear(datos: TCrear): T {
const entidad = {
...(datos as object),
id: generarId(),
creadaEn: new Date(),
actualizadaEn: new Date(),
} as T;
this.store.set(entidad.id, entidad);
return entidad;
}
actualizar(id: ID, cambios: TActualizar): T | null {
const existente = this.store.get(id);
if (!existente) return null;
const actualizada = { ...existente, ...cambios, actualizadaEn: new Date() } as T;
this.store.set(id, actualizada);
return actualizada;
}
eliminar(id: ID): boolean {
return this.store.delete(id);
}
}
// ════════════════════════════════════════════════════════════
// DECORADOR DE LOGGING
// ════════════════════════════════════════════════════════════
function log(target: unknown, context: ClassMethodDecoratorContext): void {
const nombre = String(context.name);
context.addInitializer(function (this: Record<string, unknown>) {
const original = this[nombre] as (...args: unknown[]) => unknown;
this[nombre] = function (...args: unknown[]) {
const etiqueta = `[${context.kind}:${nombre}]`;
console.log(`${etiqueta} llamado`);
const resultado = original.apply(this, args);
console.log(`${etiqueta} completado`);
return resultado;
};
});
}
// ════════════════════════════════════════════════════════════
// ESTADÍSTICAS — template literal types + mapped types
// ════════════════════════════════════════════════════════════
type ClaveEstado = EstadoTarea["tipo"];
type ClavesEstadistica = `total_${ClaveEstado}` | "total_tareas" | "porcentaje_completado";
type EstadisticasProyecto = Record<ClavesEstadistica, number>;
// ════════════════════════════════════════════════════════════
// SERVICIOS
// ════════════════════════════════════════════════════════════
class ServicioTareas {
constructor(
private readonly repoTareas: Repositorio<Tarea, CrearTareaDto, ActualizarTareaDto>
) {}
@log
crear(datos: CrearTareaDto): Tarea {
return this.repoTareas.crear(datos);
}
@log
iniciar(tareaId: ID, usuarioId: ID): Tarea | null {
const tarea = this.repoTareas.obtener(tareaId);
if (!tarea || !esPendiente(tarea)) return null;
return this.repoTareas.actualizar(tareaId, {
estado: { tipo: "en-progreso", iniciadaEn: new Date(), asignadaA: usuarioId },
actualizadaEn: new Date(),
} as ActualizarTareaDto);
}
@log
completar(tareaId: ID, notas?: string): Tarea | null {
const tarea = this.repoTareas.obtener(tareaId);
if (!tarea || !estaEnProgreso(tarea)) return null;
return this.repoTareas.actualizar(tareaId, {
estado: { tipo: "completada", completadaEn: new Date(), notas },
actualizadaEn: new Date(),
} as ActualizarTareaDto);
}
@log
cancelar(tareaId: ID, motivo: string): Tarea | null {
const tarea = this.repoTareas.obtener(tareaId);
if (!tarea || estaCancelada(tarea) || estaCompletada(tarea)) return null;
return this.repoTareas.actualizar(tareaId, {
estado: { tipo: "cancelada", motivoCancelacion: motivo },
actualizadaEn: new Date(),
} as ActualizarTareaDto);
}
obtenerPorProyecto(proyectoId: ID): Tarea[] {
return this.repoTareas.listar().filter((t) => t.proyectoId === proyectoId);
}
obtenerPorPrioridad(prioridad: Prioridad): Tarea[] {
return this.repoTareas.listar().filter((t) => t.prioridad === prioridad);
}
calcularEstadisticas(proyectoId?: ID): EstadisticasProyecto {
const tareas = proyectoId
? this.obtenerPorProyecto(proyectoId)
: this.repoTareas.listar();
const totalTareas = tareas.length;
const totalPendiente = tareas.filter(esPendiente).length;
const totalEnProgreso = tareas.filter(estaEnProgreso).length;
const totalCompletada = tareas.filter(estaCompletada).length;
const totalCancelada = tareas.filter(estaCancelada).length;
const porcentajeCompletado = totalTareas > 0
? Math.round((totalCompletada / totalTareas) * 100)
: 0;
return {
total_tareas: totalTareas,
total_pendiente: totalPendiente,
"total_en-progreso": totalEnProgreso,
total_completada: totalCompletada,
total_cancelada: totalCancelada,
porcentaje_completado: porcentajeCompletado,
};
}
}
// ════════════════════════════════════════════════════════════
// DEMO COMPLETA
// ════════════════════════════════════════════════════════════
// Repositorios
const repoUsuarios = new RepositorioEnMemoria<Usuario, CrearUsuarioDto, ActualizarUsuarioDto>();
const repoProyectos = new RepositorioEnMemoria<Proyecto, CrearProyectoDto, ActualizarProyectoDto>();
const repoTareas = new RepositorioEnMemoria<Tarea, CrearTareaDto, ActualizarTareaDto>();
const servicioTareas = new ServicioTareas(repoTareas);
// Crear usuarios
const ana = repoUsuarios.crear({
nombre: "Ana García",
email: "[email protected]",
rol: "admin",
activo: true,
});
const luis = repoUsuarios.crear({
nombre: "Luis Pérez",
email: "[email protected]",
rol: "miembro",
activo: true,
});
// Crear proyecto
const proyecto = repoProyectos.crear({
nombre: "Plataforma Web v2",
descripcion: "Rediseño completo de la plataforma",
responsableId: ana.id,
miembrosIds: [ana.id, luis.id],
archivado: false,
});
// Crear tareas
const t1 = servicioTareas.crear({
titulo: "Diseñar sistema de autenticación",
descripcion: "Implementar login con JWT y refresh tokens",
prioridad: "crítica",
etiquetas: ["auth", "seguridad", "backend"],
proyectoId: proyecto.id,
estado: { tipo: "pendiente" },
});
const t2 = servicioTareas.crear({
titulo: "Configurar pipeline de CI/CD",
descripcion: "GitHub Actions para testing y deploy automático",
prioridad: "alta",
etiquetas: ["devops", "ci-cd"],
proyectoId: proyecto.id,
estado: { tipo: "pendiente" },
});
const t3 = servicioTareas.crear({
titulo: "Documentar API pública",
descripcion: "Generar especificación OpenAPI 3.1",
prioridad: "media",
etiquetas: ["docs", "api"],
proyectoId: proyecto.id,
estado: { tipo: "pendiente" },
});
// Flujo de trabajo
console.log("\n📋 Tareas creadas:");
repoTareas.listar().forEach((t) => console.log(` - [${t.prioridad}] ${t.titulo}`));
// Iniciar tarea 1
const t1Iniciada = servicioTareas.iniciar(t1.id, luis.id);
console.log(`\n▶ Tarea iniciada: "${t1Iniciada?.titulo}" (asignada a ${luis.nombre})`);
// Completar tarea 1
const t1Completada = servicioTareas.completar(t1.id, "Sistema OAuth2 + JWT implementado con éxito");
if (t1Completada && estaCompletada(t1Completada)) {
console.log(`\n✅ Completada el ${t1Completada.estado.completadaEn.toLocaleDateString("es-ES")}`);
console.log(` Notas: ${t1Completada.estado.notas}`);
}
// Iniciar y cancelar tarea 3
servicioTareas.iniciar(t3.id, ana.id);
servicioTareas.cancelar(t3.id, "Se pospone al siguiente sprint");
// Estadísticas del proyecto
const stats = servicioTareas.calcularEstadisticas(proyecto.id);
console.log("\n📊 Estadísticas del proyecto:");
console.log(` Total de tareas: ${stats.total_tareas}`);
console.log(` Pendientes: ${stats["total_pendiente"]}`);
console.log(` En progreso: ${stats["total_en-progreso"]}`);
console.log(` Completadas: ${stats["total_completada"]}`);
console.log(` Canceladas: ${stats["total_cancelada"]}`);
console.log(` Porcentaje completado: ${stats.porcentaje_completado}%`);
// Filtrado por prioridad con type guard implícito
const tareasCriticas = servicioTareas.obtenerPorPrioridad("crítica");
console.log(`\n🔴 Tareas críticas: ${tareasCriticas.length}`);
tareasCriticas.forEach((t) => {
if (estaCompletada(t)) {
console.log(` ✅ ${t.titulo} (completada)`);
} else if (estaEnProgreso(t)) {
console.log(` ▶ ${t.titulo} (en progreso, asignada a ${t.estado.asignadaA})`);
} else {
console.log(` ⬜ ${t.titulo} (${t.estado.tipo})`);
}
});Conceptos integrados en este proyecto
Revisa cómo cada concepto del curso aparece en el código:
| Lección | Concepto | Dónde aparece |
|---|---|---|
| 1-2 | Tipos básicos e interfaces | ID, EntidadBase, Tarea, Usuario, Proyecto |
| 3 | Funciones tipadas | generarId(), calcularEstadisticas() |
| 4 | Uniones e intersecciones | EstadoTarea (discriminated union), Prioridad |
| 5 | Tipos avanzados | CrearTareaDto, ActualizarTareaDto con Omit/Pick |
| 6 | Enums y literales | "baja" | "media" | "alta" | "crítica", "admin" | "miembro" |
| 7 | Interfaces avanzadas | Repositorio<T, TCrear, TActualizar> |
| 8 | Genéricos | RepositorioEnMemoria<T, TCrear, TActualizar> |
| 9 | Genéricos avanzados | T extends EntidadBase en el repositorio genérico |
| 10 | Narrowing y type guards | esPendiente(), estaCompletada(), estaEnProgreso() |
| 11 | Utility types | Partial, Pick, Omit, Record en los DTOs y estadísticas |
| 12 | Template literal types | ClavesEstadistica, EstadisticasProyecto |
| 13 | Clases y decoradores | RepositorioEnMemoria, ServicioTareas, decorador @log |
| 14 | Módulos | Estructura separada por dominio (usuarios, proyectos, tareas) |
| 15 | Migración JS → TS | Tipado estricto desde el inicio, sin any |
Desafíos para seguir practicando
¿Quieres ir más allá? Extiende el proyecto con estas funcionalidades:
- Añade un módulo de comentarios: cada tarea puede tener comentarios con autor y fecha. Usa genéricos para un tipo
ComentarioEn<T extends EntidadBase>. - Implementa búsqueda type-safe: una función
buscar<T>(entidades: T[], filtro: Partial<T>): T[]que filtre por cualquier campo. - Añade validación con tipos condicionales: un tipo
Validado<T>que marque un objeto como validado y evite operar con datos no validados. - Crea un tipo de error de dominio:
ErrorDominio<TContexto>coninferpara extraer el tipo del contexto automáticamente en el manejador de errores. - Persiste en localStorage: crea un repositorio alternativo
RepositorioLocalStorage<T>que serialice y deserialice los datos, respetando la interfaz genérica.
¡Enhorabuena por completar el curso de TypeScript completo! Has pasado de los tipos básicos hasta los mapped types recursivos, los decoradores TC39 y el patrón repositorio genérico. TypeScript ya no es solo un superset de JavaScript para ti: es una herramienta de diseño que te ayuda a modelar el dominio de tu aplicación de forma precisa y segura.
Inicia sesión para guardar tu progreso