02
Proceso y requisitos

Procesos, planificación y estimación

Proceso y requisitos

Semana 2 — Procesos, planificación y estimación

Objetivos

  • Comparar los procesos en cascada, iterativo e incremental, Scrum y Kanban: qué supone cada uno sobre los requisitos, el riesgo y la retroalimentación, y cuándo conviene cada uno.
  • Describir Scrum como lo define la Scrum Guide 2020: sus tres responsabilidades, sus cinco eventos y sus tres artefactos, cada uno con su compromiso.
  • Estimar con story points y planning poker, distinguir una estimación de un compromiso y planificar un sprint con una velocity observada.
  • Analizar el caso del Boeing 737 MAX: cómo la presión de costos y de calendario, unos supuestos de diseño equivocados y la información ocultada llevaron a dos accidentes.
  • Organizar el trabajo de un equipo en GitHub con issues, etiquetas y un tablero.

Sesión A — Procesos: cascada, iterativo, Scrum y Kanban

Lecturas:

Lecturas complementarias:

  • The Kanban Guide (Vacanti y Coleman): las tres prácticas y las cuatro métricas de flujo.
  • Atlassian, Scrum y Kanban: cómo se ven en una herramienta de seguimiento.

Temas:

  • Un proceso de desarrollo es el acuerdo del equipo sobre cómo pasa el trabajo de una idea a software en uso: quién decide qué se construye, en qué orden, cada cuánto se entrega y cómo se corrige el rumbo. No hay un proceso correcto en abstracto. Cada proceso hace una apuesta sobre qué tan estables son los requisitos y qué tan caro es cambiar.
  • Cascada: requisitos, diseño, implementación, verificación y mantenimiento, una fase después de otra, con documentos que pasan de una a la siguiente. Funciona cuando los requisitos se conocen y casi no cambian, y cuando el costo de corregir tarde es aceptable. Su riesgo: el cliente ve el producto al final, cuando cambiar es lo más caro. Error común: creer que el artículo de Royce de 1970, que se cita como origen de la cascada, la recomienda. Royce la presenta como riesgosa y propone un diseño preliminar, hacer el trabajo dos veces e involucrar al cliente.
  • Iterativo e incremental: se entrega software que funciona en ciclos cortos y cada ciclo agrega una parte. En Kallpa: primero reservar una hora, después las cancelaciones, después la caja del día. Cada entrega es una pregunta al cliente: “¿esto es lo que necesitaban?”.
  • El Manifiesto Ágil (2001): cuatro valores (“individuos e interacciones sobre procesos y herramientas”, “software funcionando sobre documentación extensiva”, “colaboración con el cliente sobre negociación contractual” y “respuesta ante el cambio sobre seguir un plan”) y doce principios. Lo de la derecha también vale; lo de la izquierda vale más.
  • Scrum según la Scrum Guide 2020. Un Scrum Team con tres responsabilidades (accountabilities, ya no “roles”): el product owner, que maximiza el valor y ordena el product backlog; el Scrum master, que hace que el equipo use Scrum bien; y los developers, que hacen el trabajo (la guía de 2020 dejó de hablar de “equipo de desarrollo”). Cinco eventos: el sprint (un mes o menos), que contiene a la sprint planning, la daily, la sprint review y la retrospectiva. Tres artefactos, cada uno con su compromiso: el product backlog con el product goal, el sprint backlog con el sprint goal y el incremento con la Definition of Done.
  • Error común: hacer las reuniones de Scrum sin que nada cambie. Una daily que es un informe al jefe, o una retrospectiva sin una acción concreta para el siguiente sprint, tienen el costo de Scrum sin su beneficio.
  • Kanban: el trabajo fluye por un tablero y se limita el trabajo en curso (WIP, work in progress). Si la columna “En revisión” tiene un límite de 3 y ya hay 3 tarjetas, nadie empieza nada nuevo: ayudan a revisar. La Kanban Guide mide el flujo con cuatro métricas: WIP, throughput (ítems terminados por unidad de tiempo), work item age y cycle time. Y la ley de Little, que viene de la teoría de colas, dice que con el mismo throughput más WIP significa más cycle time.
  • Cuál elegir: Scrum ayuda cuando hay un cliente que revisa cada cierto tiempo y un objetivo por sprint, como en el proyecto del curso. Kanban encaja con trabajo continuo que llega sin aviso, como el soporte de Kallpa en producción. Muchos equipos mezclan ambos.

Sesión B — Planificación y estimación · Caso: el Boeing 737 MAX

Lecturas:

Lecturas complementarias:

Temas:

  • Estimar no es adivinar ni prometer. Una estimación dice cuánto trabajo creemos que hay, con su incertidumbre; un compromiso es lo que el equipo acepta entregar; un objetivo es lo que el negocio quisiera. Confundirlos produce fechas imposibles.
  • Story points: una medida relativa del tamaño de una historia (trabajo, complejidad e incertidumbre juntos), no de horas. “Filtrar las canchas por deporte” es un 2 si “agregar los feriados a la tarifa” es un 3. Se usa una escala con huecos (1, 2, 3, 5, 8, 13) porque no se puede distinguir un 6 de un 7. Una historia de 13 o más es demasiado grande y se divide.
  • Planning poker: cada developer elige una carta en secreto y todos las muestran a la vez. Si salen 2 y 8, quienes las sacaron explican por qué: casi siempre uno sabe algo que el otro no (“hay que tocar la base de datos”, “eso ya existe en calendar.ts”). Se vuelve a votar. Lo que se gana es la conversación, no el número.
  • Velocity: los puntos terminados por sprint, según la Definition of Done. Con la velocity de los últimos sprints se planifica el siguiente. No sirve para comparar equipos: cada equipo tiene su propia escala. Error común: usarla como meta de desempeño. Por la ley de Goodhart, cuando una medida se vuelve meta deja de ser una buena medida, y los puntos se inflan. El cap. 7 de SWEG propone medir con el marco Goals/Signals/Metrics: primero la pregunta, después la métrica.
  • Planificar un sprint: el product owner trae el backlog ordenado; el equipo propone un sprint goal, elige historias hasta su capacidad y las descompone en tareas. Un burndown chart muestra lo que falta día a día. El plan se revisa al final de cada sprint, con lo que se aprendió.
  • Caso: el 737 MAX. Lion Air 610 (29 de octubre de 2018) y Ethiopian 302 (10 de marzo de 2019): 346 muertos. El MCAS es un software que baja automáticamente la nariz del avión en ciertas condiciones. Hizo lo que estaba diseñado para hacer con un dato equivocado, porque se activaba con la lectura de un solo sensor de ángulo de ataque. El informe del Congreso de EE. UU. identifica cinco temas: presión de producción, supuestos de diseño y desempeño equivocados, cultura de ocultamiento, conflictos de interés en la supervisión e influencia de Boeing sobre la FAA, la autoridad aeronáutica que debía supervisarla.
  • Las lecciones del caso para el curso. Presión de calendario y costo: un contrato obligaba a Boeing a descontar al menos un millón de dólares por avión a Southwest si la FAA exigía entrenamiento en simulador, y evitar ese entrenamiento se volvió un requisito del programa. Un cambio de alcance sin reevaluar el riesgo: en 2016 se rediseñó el MCAS para que actuara también a baja velocidad, con más autoridad, y las personas de la FAA que aprobaron sacarlo del manual de los pilotos no supieron del rediseño. Un supuesto sobre las personas: las guías de la FAA asumen que un piloto reacciona en 4 segundos, pero en un simulador de Boeing un piloto de pruebas tardó más de 10. Y una alerta de “AOA Disagree” que no funcionaba en la mayoría de los aviones, y no se informó.
  • Error común: resumir el caso como “un bug de software”. El código hizo lo especificado. Fallaron los requisitos, el análisis de riesgo, la revisión independiente y la honestidad sobre lo que se sabía. Son temas de este curso: semanas 3 y 4 (requisitos y especificaciones), 11 (revisión) y 15 (ética).
  • Con IA. Una herramienta que escribe código rápido no cambia el trabajo de entender qué construir, revisar y probar; con frecuencia ese trabajo crece. El reporte de DORA de 2025 encuentra que la IA amplifica lo que el equipo ya hace bien o mal. Estimen por el trabajo completo hasta la Definition of Done, no por el tiempo de escribir el código.

Laboratorio — Issues, tablero y estimación del backlog de Kallpa

Guía: este documento. Referencias: GitHub, About issues y About Projects; manual de gh issue create; Agile Alliance, Definition of Done.

En grupos de 4 o 5 (de preferencia, el futuro equipo del proyecto), sobre el repositorio de Kallpa de una de las personas del grupo (el de la semana 1). El backlog de partida está en code/week-02/backlog.md: pedidos de la administración y de los vecinos, unos claros y otros vagos, unos pequeños y otros enormes.

  1. Agregar a las demás personas del grupo como colaboradoras del repositorio. Crear etiquetas por tipo (bug, funcionalidad, mejora) y por área (reservas, precios, caja, página), en la web o con gh label create precios --color FBCA04 --description "Tarifas y descuentos". El contenedor no guarda credenciales: para usar gh dentro de él, primero gh auth login, y para los proyectos, además, gh auth refresh -s project.
  2. Repartir los pedidos de backlog.md y crear un issue por cada uno, con título, contexto y etiquetas (en la web, o con gh issue create --title "..." --body-file pedido.md --label funcionalidad --label caja). Si un pedido es vago, el issue lo dice: escriban como comentario las preguntas que le harían al cliente.
  3. Crear un proyecto de GitHub (Projects) con vista de tablero y columnas Backlog, Listo, En curso, En revisión y Hecho. Ponerle un límite de 2 a En curso y de 3 a En revisión (en GitHub el límite solo resalta el conteo y no impide agregar tarjetas: lo hace cumplir el equipo). Agregar un campo numérico Puntos. Agregar todos los issues al tablero.
  4. Escribir la Definition of Done del grupo para Kallpa en un issue fijado (por ejemplo: make check pasa, hay pruebas de los criterios de aceptación, el pull request tiene una revisión aprobada y el README está al día si cambió la API).
  5. Planning poker con cartas 1, 2, 3, 5, 8, 13 y ? (sirven papelitos). Estimar los 15 pedidos. Cuando las cartas difieran en más de un salto, quienes tienen la más alta y la más baja explican su razón antes de volver a votar. Anotar en el issue la razón que hizo cambiar la estimación.
  6. Dividir en historias más pequeñas al menos un pedido que haya salido 13 o ”?”, y estimar las partes. ¿Suman lo mismo que el original?
  7. Planificar un sprint de una semana con una capacidad supuesta de 10 puntos: escribir el sprint goal en una frase, mover a Listo los issues elegidos y justificar en el tablero (o en un comentario) qué quedó afuera y por qué.
  8. Cierre: ¿qué pasaría con el tablero si el grupo trabajara con Kanban en lugar de sprints? Escriban la respuesta en tres líneas en el issue fijado.

Entregas

Preguntas de repaso

  1. Den un proyecto donde la cascada sea razonable y otro donde sea una mala idea. ¿Qué tiene que ser cierto sobre los requisitos para que funcione?
  2. En la Scrum Guide 2020, ¿cuál es el compromiso de cada artefacto? ¿Qué pasa con una historia que no cumple la Definition of Done al final del sprint?
  3. En una estimación con planning poker salen un 2 y un 13 para la misma historia. ¿Qué hace el equipo? ¿Por qué no se promedia?
  4. Una gerencia decide premiar al equipo con la mayor velocity. ¿Qué va a pasar? Expliquen con la ley de Goodhart y propongan qué medir en su lugar.
  5. ¿Por qué es incorrecto decir que el 737 MAX cayó por un bug de software? Nombren dos decisiones del caso que hoy reconocerían en un proyecto de software común.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (8)Pruebas (4)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

backlog.mdDescargar backlog.md
# Backlog de partida de Kallpa

Estos son los pedidos que juntó la administración del complejo en las últimas
semanas: mensajes de WhatsApp, lo que se anotó en el cuaderno de la caja y lo
que se dijo en la última asamblea de la junta directiva. Están tal como
llegaron, en el orden en que llegaron, sin ordenar por importancia. Unos son
claros y otros vagos; unos se hacen en una tarde y otros llevarían meses.

En el laboratorio de la semana 2 cada pedido se convierte en un issue de
GitHub. Las reglas actuales del complejo (horario, precios, cancelaciones)
están en el README de Kallpa. Ninguno de estos pedidos repite un issue de P0.

Personas que aparecen:

- **Doris Chumpitaz**, administración del complejo.
- **Marleny Ccahuana**, atiende la caja de lunes a sábado.
- **Hernán Salas**, tesorero de la junta directiva.
- **Don Teodoro Llanos**, presidente de la junta directiva.
- Vecinos que reservan: **Milagros Vargas**, **Kevin Mendoza**, **Carmen
  Flores**, **Renzo Gutiérrez** y **Brayan Quispe**.

---

## 1. Ver el precio antes de reservar

**Quién lo pide:** Milagros Vargas, vecina · **Cómo llegó:** mensaje de WhatsApp

> "Cuando reservo recién me dice cuánto es, cuando ya está hecho. El otro día
> reservé dos horas de fulbito pensando que era menos y tuve que cancelar.
> ¿No se puede ver el precio antes de darle a reservar?"

## 2. Anotarse en una lista de espera

**Quién lo pide:** Kevin Mendoza, capitán de un equipo de fulbito · **Cómo llegó:** mensaje de WhatsApp

> "Los viernes de 8 a 10 de la noche las dos canchas de fulbito siempre están
> tomadas. A veces alguien cancela a última hora y la cancha se queda vacía
> porque nadie se entera. Quisiera anotarme en una lista y que me avisen si se
> libera. Si somos varios en la lista, que le toque al primero que se anotó."

## 3. Recordar la reserva un día antes

**Quién lo pide:** Doris Chumpitaz, administración · **Cómo llegó:** conversación en la oficina

> "Hay equipos que reservan y no vienen, y cuando los llamo me dicen que se
> olvidaron. Que el sistema les mande un WhatsApp un día antes para
> recordarles. Así también, si ya no van a venir, cancelan a tiempo."

## 4. El campeonato de verano

**Quién lo pide:** Don Teodoro Llanos, presidente de la junta · **Cómo llegó:** acta de la asamblea de vecinos

> "En enero y febrero hacemos el campeonato interbarrios de fulbito. Este año
> esperamos 16 equipos. Queremos que todo se haga en la página: que los
> equipos se inscriban y paguen su inscripción, que se arme el fixture, que
> las canchas de los partidos queden reservadas para el campeonato, y que se
> publiquen los resultados y la tabla de posiciones. El año pasado todo fue en
> cuadernos y hubo peleas por los puntos."

## 5. Descargar la caja del día

**Quién lo pide:** Marleny Ccahuana, caja · **Cómo llegó:** nota en el cuaderno de la caja

> "Al cerrar, copio a mano en el cuaderno de tesorería lo que sale en la caja
> del día: cada reserva, la cancha, las horas y el monto. Me demoro media hora
> y a veces me equivoco. Quisiera descargarlo en un archivo que se abra en
> Excel y mandárselo al señor Hernán."

## 6. Un resumen del mes para la asamblea

**Quién lo pide:** Hernán Salas, tesorero · **Cómo llegó:** correo a la administración

> "Cada mes tengo que presentar en la asamblea cuánto entró por cada cancha y
> compararlo con el mes anterior. Hoy sumo a mano las cajas de cada día. Si
> el sistema ya tiene los datos, debería poder darme el resumen del mes."

## 7. Alquilar pelota y chalecos con la reserva

**Quién lo pide:** Marleny Ccahuana, caja · **Cómo llegó:** conversación en la caja

> "Prestamos pelotas y chalecos y los cobramos aparte: la pelota S/ 5 y el
> juego de chalecos S/ 10. Como no queda registrado, a fin de mes no cuadra lo
> que se cobró. Que al reservar se pueda pedir pelota y chalecos y que se
> sume al precio. Ojo, que solo tenemos tres pelotas de fulbito y dos de
> vóley."

## 8. Cambiar la hora de una reserva

**Quién lo pide:** Carmen Flores, vecina · **Cómo llegó:** mensaje de WhatsApp

> "Si quiero pasar mi reserva de las 7 a las 8 de la noche tengo que
> cancelarla y volver a reservar, y en ese ratito alguien me ganó la hora.
> Quiero poder cambiar la hora (o el día) de mi reserva sin perderla."

## 9. Nombres de equipo más largos

**Quién lo pide:** Renzo Gutiérrez, vecino · **Cómo llegó:** mensaje de WhatsApp

> "Nuestro equipo se llama *Club Deportivo Juventud Unida de San Martín de
> Porres* y la página no me deja escribirlo completo. Tuve que poner
> *Juventud Unida SMP* y los muchachos se quejan."

## 10. Que la página se vea bien en el celular

**Quién lo pide:** Brayan Quispe, vecino · **Cómo llegó:** comentario en el grupo de WhatsApp de la junta

> "Todos reservamos desde el celular y la página está hecha para
> computadora. Hay que hacer zoom para todo y a veces le das a la hora que no
> es."

## 11. La página demora los sábados

**Quién lo pide:** Don Teodoro Llanos, presidente de la junta · **Cómo llegó:** asamblea de vecinos

> "Varios vecinos se han quejado de que los sábados en la mañana la página es
> lentísima. Que la arreglen para que sea rápida."

## 12. Sancionar a quien reserva y no viene

**Quién lo pide:** Doris Chumpitaz, administración · **Cómo llegó:** acta de la asamblea de vecinos

> "La asamblea aprobó que quien reserve y no venga tres veces se quede un mes
> sin poder reservar. Mientras tanto la cancha se queda vacía y otros equipos
> se quedan sin jugar. Necesitamos que el sistema lo controle."

## 13. Tarifa para el colegio

**Quién lo pide:** Doris Chumpitaz, administración · **Cómo llegó:** convenio firmado con la dirección del colegio

> "Firmamos un convenio con el colegio de la avenida: sus profesores de
> educación física pueden usar las canchas de vóley y de básquet de lunes a
> viernes en la mañana a mitad de precio. El colegio ya está registrado como
> vecino. El convenio vale hasta diciembre."

## 14. Avisar que algo está malogrado

**Quién lo pide:** Kevin Mendoza, capitán de un equipo de fulbito · **Cómo llegó:** mensaje de WhatsApp

> "La luz del fondo de la cancha 2 está quemada hace dos semanas y el arco de
> la cancha 1 tiene la red rota. Se lo dije al guardián, pero no sé si llegó
> a la administración. Debería haber una forma de avisar desde la página y de
> ver si ya lo están arreglando."

## 15. "A mi amigo le cobraron menos"

**Quién lo pide:** Renzo Gutiérrez, vecino · **Cómo llegó:** reclamo en la caja, anotado por Marleny

> "El jueves reservé una hora de fulbito a las 10 de la mañana y me cobraron
> S/ 90. Mi amigo Jorge reservó la misma cancha a la misma hora el viernes y
> pagó S/ 81. Es la misma cancha y la misma hora. Revisen el sistema, que está
> cobrando mal."
eslint.config.jsDescargar eslint.config.js
// Configuración de ESLint: las mismas reglas recomendadas que usa Kallpa.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig({ ignores: ['build/'] }, js.configs.recommended, tseslint.configs.recommended);
examples.tsDescargar examples.ts
// Datos de ejemplo del capítulo: el historial de un tablero, un sprint ya
// cerrado y las historias en que se dividió un pedido grande.

import type { WorkItem } from './flow.ts';
import type { BacklogItem, SprintItem } from './velocity.ts';

// Diez días de un tablero de Kallpa. K-9 empezó el día 9 y sigue en curso.
export const BOARD_HISTORY: readonly WorkItem[] = [
  { id: 'K-1', started: 1, finished: 4 },
  { id: 'K-2', started: 1, finished: 1 },
  { id: 'K-3', started: 2, finished: 5 },
  { id: 'K-4', started: 3, finished: 9 },
  { id: 'K-5', started: 4, finished: 8 },
  { id: 'K-6', started: 5, finished: 7 },
  { id: 'K-7', started: 6, finished: 10 },
  { id: 'K-8', started: 8, finished: 10 },
  { id: 'K-9', started: 9 },
];

// El sprint 3 de un equipo: lo que tenía al cierre y si cumplía la Definition of Done.
export const SPRINT_3: readonly SprintItem[] = [
  { id: 'Mostrar el reglamento en la página', points: 1, done: true },
  { id: 'Preguntas frecuentes en la página', points: 3, done: true },
  { id: 'Calendario de la semana de una cancha', points: 5, done: true },
  { id: 'Avisar por WhatsApp si el complejo cierra un día', points: 3, done: false },
];

// Velocity de los tres sprints cerrados de ese equipo.
export const PAST_VELOCITIES: readonly number[] = [8, 11, 9];

// "Vacaciones útiles": clases de vóley y básquet para niños en enero y
// febrero, dividido en historias, en el orden en que las puso el product owner.
export const SUMMER_CLASSES: readonly BacklogItem[] = [
  { id: 'V1 Publicar horarios y cupos de las clases', points: 2 },
  { id: 'V2 Inscribir a un niño en una clase', points: 5 },
  { id: 'V3 Cerrar la inscripción al llenarse el cupo', points: 2 },
  { id: 'V5 Cobrar la mensualidad y verla en la caja', points: 5 },
  { id: 'V4 Lista de inscritos para el profesor', points: 1 },
  { id: 'V6 Descuento por hermanos', points: 3 },
  { id: 'V7 Avisar al apoderado si se suspende una clase', points: 3 },
];
flow-demo.tsDescargar flow-demo.ts
// Las métricas de flujo del tablero de ejemplo y la ley de Little en una
// simulación. Uso: node flow-demo.ts

import { BOARD_HISTORY } from './examples.ts';
import { averageWip, cycleTime, flowSummary, simulateBoard, throughput, wipOn, workItemAge } from './flow.ts';

const fmt = (n: number): string => n.toFixed(2);

console.log('Tablero de Kallpa, días 1 a 10');
console.log('día  1  2  3  4  5  6  7  8  9 10');
for (const item of BOARD_HISTORY) {
  let row = '';
  for (let day = 1; day <= 10; day++) {
    const active = item.started <= day && (item.finished === undefined || day <= item.finished);
    row += active ? '  #' : '  .';
  }
  const ct = item.finished === undefined ? `edad ${workItemAge(item, 10)}` : `cycle time ${cycleTime(item)}`;
  console.log(`${item.id.padEnd(4)}${row}   ${ct}`);
}
let wipRow = 'WIP ';
for (let day = 1; day <= 10; day++) wipRow += String(wipOn(BOARD_HISTORY, day)).padStart(3);
console.log(wipRow);

const finished = BOARD_HISTORY.filter((i) => i.finished !== undefined);
const s = flowSummary(finished, 1, 10);
console.log('\nSolo los ítems terminados (K-1 a K-8):');
console.log(`  WIP promedio ${fmt(s.averageWip)} | throughput ${fmt(s.throughput)} por día | cycle time promedio ${fmt(s.averageCycleTime)} días`);
console.log(`  throughput × cycle time = ${fmt(s.throughput * s.averageCycleTime)}`);
console.log('Con K-9, que sigue en curso:');
console.log(`  WIP promedio ${fmt(averageWip(BOARD_HISTORY, 1, 10))} | throughput ${fmt(throughput(BOARD_HISTORY, 1, 10))} por día`);

console.log('\nSimulación: el equipo termina 2 ítems por día; días 11 a 30');
for (const limit of [4, 6, 10]) {
  const items = simulateBoard(limit, 2, 40);
  const window = items.filter((i) => i.finished !== undefined && i.finished >= 11 && i.finished <= 30);
  const avgCt = window.reduce((sum, i) => sum + cycleTime(i), 0) / window.length;
  console.log(
    `  límite ${String(limit).padStart(2)}: WIP promedio ${fmt(averageWip(items, 11, 30))} | ` +
      `throughput ${fmt(throughput(items, 11, 30))} | cycle time promedio ${fmt(avgCt)} días`,
  );
}
flow.tsDescargar flow.ts
// Métricas de flujo de Kanban a partir del historial de un tablero.
//
// Los días se cuentan con enteros (día 1, día 2, ...). Un ítem está "en curso"
// desde el día en que empezó hasta el día en que terminó, ambos incluidos: un
// ítem que empieza y termina el mismo día tiene un cycle time de 1 día. Con esta
// convención, cada día en que un ítem está en curso suma 1 al WIP de ese día.

export type WorkItem = {
  id: string;
  started: number; // día en que pasó a "En curso"
  finished?: number; // día en que pasó a "Hecho"; sin valor, todavía está en curso
};

function checkItem(item: WorkItem): void {
  if (!Number.isInteger(item.started)) throw new RangeError(`${item.id}: día de inicio inválido`);
  if (item.finished !== undefined && (!Number.isInteger(item.finished) || item.finished < item.started)) {
    throw new RangeError(`${item.id}: termina antes de empezar`);
  }
}

// Cycle time: días entre que empezó y terminó, ambos incluidos.
export function cycleTime(item: WorkItem): number {
  checkItem(item);
  if (item.finished === undefined) throw new RangeError(`${item.id}: todavía no termina`);
  return item.finished - item.started + 1;
}

// Work item age: días que lleva en curso un ítem que no ha terminado, contando hoy.
export function workItemAge(item: WorkItem, today: number): number {
  checkItem(item);
  if (item.finished !== undefined && item.finished <= today) {
    throw new RangeError(`${item.id}: ya terminó; su edad no cambia, se mide su cycle time`);
  }
  if (today < item.started) throw new RangeError(`${item.id}: todavía no empieza`);
  return today - item.started + 1;
}

export function inProgressOn(item: WorkItem, day: number): boolean {
  return item.started <= day && (item.finished === undefined || day <= item.finished);
}

// WIP de un día: cuántos ítems estaban en curso ese día.
export function wipOn(items: readonly WorkItem[], day: number): number {
  return items.filter((item) => inProgressOn(item, day)).length;
}

// Throughput: ítems terminados por día en los días from..to (ambos incluidos).
export function throughput(items: readonly WorkItem[], from: number, to: number): number {
  const days = to - from + 1;
  const done = items.filter((i) => i.finished !== undefined && from <= i.finished && i.finished <= to);
  return done.length / days;
}

export function averageWip(items: readonly WorkItem[], from: number, to: number): number {
  let total = 0;
  for (let day = from; day <= to; day++) total += wipOn(items, day);
  return total / (to - from + 1);
}

export function averageCycleTime(items: readonly WorkItem[]): number {
  const done = items.filter((i) => i.finished !== undefined);
  if (done.length === 0) throw new RangeError('no hay ítems terminados');
  return done.reduce((sum, i) => sum + cycleTime(i), 0) / done.length;
}

export type FlowSummary = {
  averageWip: number;
  throughput: number;
  averageCycleTime: number;
};

export function flowSummary(items: readonly WorkItem[], from: number, to: number): FlowSummary {
  return {
    averageWip: averageWip(items, from, to),
    throughput: throughput(items, from, to),
    averageCycleTime: averageCycleTime(items),
  };
}

// Simulación de un tablero con límite de WIP. Cada día el equipo termina, en
// orden de llegada, hasta `perDay` ítems que ya estaban en curso, y después
// empieza ítems nuevos del backlog (que nunca se acaba) hasta llegar al límite.
// La capacidad del equipo (perDay) no depende del WIP: es una simplificación.
export function simulateBoard(wipLimit: number, perDay: number, days: number): WorkItem[] {
  if (!Number.isInteger(wipLimit) || wipLimit < 1) throw new RangeError('límite de WIP inválido');
  if (!Number.isInteger(perDay) || perDay < 1) throw new RangeError('capacidad inválida');
  const items: WorkItem[] = [];
  const open: WorkItem[] = [];
  for (let day = 1; day <= days; day++) {
    // Empezar lo nuevo antes de trabajar, hasta el límite.
    while (open.length < wipLimit) {
      const item: WorkItem = { id: `T-${items.length + 1}`, started: day };
      items.push(item);
      open.push(item);
    }
    // Terminar lo más antiguo.
    for (const item of open.splice(0, perDay)) item.finished = day;
  }
  return items;
}
plan-demo.tsDescargar plan-demo.ts
// Velocity, pronóstico, una primera propuesta de sprint y su burndown.
// Uso: node plan-demo.ts

import { PAST_VELOCITIES, SPRINT_3, SUMMER_CLASSES } from './examples.ts';
import { burndown, fillSprint, forecastSprints, idealLine, velocity } from './velocity.ts';

console.log('Sprint 3 al cierre:');
for (const item of SPRINT_3) console.log(`  ${item.done ? 'hecho   ' : 'no hecho'} ${String(item.points).padStart(2)}  ${item.id}`);
console.log(`  velocity: ${velocity(SPRINT_3)} puntos`);

const total = SUMMER_CLASSES.reduce((sum, i) => sum + i.points, 0);
const f = forecastSprints(total, PAST_VELOCITIES);
console.log(`\nVelocity de los sprints cerrados: ${PAST_VELOCITIES.join(', ')} (promedio ${f.average.toFixed(1)})`);
console.log(`Vacaciones útiles: ${total} puntos -> entre ${f.fewest} y ${f.most} sprints`);

const plan = fillSprint(SUMMER_CLASSES, 10);
console.log('\nPrimera propuesta para un sprint de 10 puntos:');
for (const item of SUMMER_CLASSES) {
  const verdict = plan.selected.includes(item) ? 'entra' : 'queda';
  console.log(`  ${verdict}   ${String(item.points).padStart(2)}  ${item.id}`);
}
console.log(`  total: ${plan.points} puntos`);

console.log('\nBurndown (puntos que faltan al final de cada día):');
const row = (values: number[]): string => values.map((v) => String(v).padStart(3)).join('');
console.log(`  día              ${row([0, 1, 2, 3, 4, 5])}`);
console.log(`  ideal            ${row(idealLine(10, 5))}`);
// V1 termina el día 2, V4 el día 4, V2 y V3 el día 5.
console.log(`  todo terminado   ${row(burndown(10, [0, 2, 0, 1, 7]))}`);
// Lo mismo, pero V2 no cumple la Definition of Done el último día.
console.log(`  V2 sin terminar  ${row(burndown(10, [0, 2, 0, 1, 2]))}`);
poker.tsDescargar poker.ts
// Las cartas del planning poker del laboratorio y la regla para decidir cuándo
// hay que conversar antes de volver a votar.

export const CARDS = [1, 2, 3, 5, 8, 13] as const;

export type Card = (typeof CARDS)[number];
export type Vote = Card | '?';

// Cuántos saltos de la escala hay entre dos cartas: de 3 a 5 hay uno, de 3 a 8, dos.
export function stepsBetween(a: Card, b: Card): number {
  return Math.abs(CARDS.indexOf(a) - CARDS.indexOf(b));
}

function numeric(votes: readonly Vote[]): Card[] {
  return votes.filter((v): v is Card => v !== '?');
}

// Hay que conversar si alguien sacó "?" o si la carta más alta y la más baja
// están a más de un salto. No se promedia: el número que importa sale de la
// conversación.
export function needsDiscussion(votes: readonly Vote[]): boolean {
  if (votes.length === 0) throw new RangeError('no hay votos');
  if (votes.includes('?')) return true;
  const cards = numeric(votes);
  return stepsBetween(Math.min(...cards) as Card, Math.max(...cards) as Card) > 1;
}

// Quiénes explican primero: la carta más baja y la más alta.
export function extremes(votes: readonly Vote[]): { low: Card; high: Card } | undefined {
  const cards = numeric(votes);
  if (cards.length === 0) return undefined;
  return { low: Math.min(...cards) as Card, high: Math.max(...cards) as Card };
}

// Un pedido que sale 13 o "?" es demasiado grande o demasiado incierto para un
// sprint: se divide (o se pregunta más) antes de planificarlo.
export function mustSplit(estimate: Vote): boolean {
  return estimate === '?' || estimate === 13;
}

// Si todos coinciden o están a un salto, el equipo puede quedarse con la carta
// más votada (la mayor, si empatan). Si no, todavía no hay estimación.
export function agreedEstimate(votes: readonly Vote[]): Card | undefined {
  if (needsDiscussion(votes)) return undefined;
  const counts = new Map<Card, number>();
  for (const c of numeric(votes)) counts.set(c, (counts.get(c) ?? 0) + 1);
  let best: Card | undefined;
  for (const c of CARDS) {
    const n = counts.get(c) ?? 0;
    if (n > 0 && (best === undefined || n >= (counts.get(best) ?? 0))) best = c;
  }
  return best;
}
velocity.tsDescargar velocity.ts
// Velocity, pronóstico por rangos y burndown de un sprint.
//
// La velocity es una medición hecha después del sprint: los puntos de los
// ítems que cumplieron la Definition of Done. Un ítem a medio hacer no suma
// nada, ni siquiera "la mitad".

export type SprintItem = {
  id: string;
  points: number;
  done: boolean; // cumple la Definition of Done al cierre del sprint
};

export function velocity(items: readonly SprintItem[]): number {
  return items.filter((i) => i.done).reduce((sum, i) => sum + i.points, 0);
}

export type Forecast = {
  fewest: number; // sprints si se repite la mejor velocity observada
  most: number; // sprints si se repite la peor
  average: number; // velocity promedio, solo como referencia
};

// Cuántos sprints harían falta para `remaining` puntos, con la velocity de los
// sprints ya cerrados. Se da un rango, no un número: el futuro se parecerá al
// pasado solo si el equipo, la escala y la Definition of Done no cambian.
export function forecastSprints(remaining: number, pastVelocities: readonly number[]): Forecast {
  if (pastVelocities.length === 0) throw new RangeError('hace falta al menos un sprint cerrado');
  if (pastVelocities.some((v) => v <= 0)) throw new RangeError('una velocity de 0 no sirve para pronosticar');
  const best = Math.max(...pastVelocities);
  const worst = Math.min(...pastVelocities);
  const average = pastVelocities.reduce((a, b) => a + b, 0) / pastVelocities.length;
  return { fewest: Math.ceil(remaining / best), most: Math.ceil(remaining / worst), average };
}

export type BacklogItem = { id: string; points: number };

export type SprintSelection = {
  selected: BacklogItem[];
  left: BacklogItem[];
  points: number;
};

// Recorre el backlog en el orden del product owner y toma lo que cabe en la
// capacidad. Si un ítem no cabe, lo deja y sigue con el siguiente. Es solo una
// primera propuesta: en la sprint planning manda el sprint goal.
export function fillSprint(ordered: readonly BacklogItem[], capacity: number): SprintSelection {
  const selected: BacklogItem[] = [];
  const left: BacklogItem[] = [];
  let points = 0;
  for (const item of ordered) {
    if (points + item.points <= capacity) {
      selected.push(item);
      points += item.points;
    } else {
      left.push(item);
    }
  }
  return { selected, left, points };
}

// Burndown: los puntos que faltan al final de cada día. Un ítem baja la curva
// solo el día en que queda terminado. El primer valor es el total del sprint.
export function burndown(total: number, pointsDonePerDay: readonly number[]): number[] {
  const remaining = [total];
  for (const done of pointsDonePerDay) remaining.push(remaining[remaining.length - 1] - done);
  return remaining;
}

// La línea ideal: bajar lo mismo cada día hasta llegar a 0.
export function idealLine(total: number, days: number): number[] {
  return Array.from({ length: days + 1 }, (_, d) => Math.round((total * (days - d) * 10) / days) / 10);
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

backlog.test.tsDescargar backlog.test.ts
// Revisa que backlog.md tenga lo que pide el laboratorio: exactamente 15
// pedidos, numerados, con quién los pide y sus palabras, y que no repita los
// issues de P0.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const text = readFileSync(new URL('./backlog.md', import.meta.url), 'utf8');
const sections = text.split(/^## /m).slice(1);

test('hay exactamente 15 pedidos, numerados del 1 al 15', () => {
  assert.equal(sections.length, 15);
  sections.forEach((s, i) => assert.match(s, new RegExp(`^${i + 1}\\. \\S`)));
});

test('los títulos no se repiten', () => {
  const titles = sections.map((s) => s.split('\n')[0]!.replace(/^\d+\. /, '').toLowerCase());
  assert.equal(new Set(titles).size, titles.length);
});

test('cada pedido dice quién lo pide, cómo llegó y trae sus palabras', () => {
  for (const s of sections) {
    assert.match(s, /\*\*Quién lo pide:\*\* .+ · \*\*Cómo llegó:\*\* .+/);
    assert.match(s, /^> "/m);
  }
});

test('ningún pedido repite un issue de P0', () => {
  const p0 = [
    /feriado/i, // 01 feriados
    /no existe/i, // 02 cancha inexistente
    /horas seguidas/i, // 03 límite de horas seguidas
    /registrar (a )?(los )?vecinos?/i, // 04 registro de vecinos
    /por deporte/i, // 05 filtro por deporte
    /30 de febrero|mes 13/i, // 06 fechas imposibles
    /domingo/i, // 07 horario de domingo
    /devoluci|reembols|devuelv/i, // 08 devolución al cancelar
  ];
  for (const s of sections) for (const re of p0) assert.doesNotMatch(s, re);
});
flow.test.tsDescargar flow.test.ts
// Pruebas de las métricas de flujo y de la ley de Little (flow.ts).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BOARD_HISTORY } from './examples.ts';
import { averageWip, cycleTime, flowSummary, simulateBoard, throughput, wipOn, workItemAge } from './flow.ts';

test('un ítem que empieza y termina el mismo día tiene cycle time 1', () => {
  assert.equal(cycleTime({ id: 'a', started: 3, finished: 3 }), 1);
  assert.equal(cycleTime({ id: 'b', started: 3, finished: 7 }), 5);
});

test('la edad solo se mide en ítems que siguen en curso', () => {
  assert.equal(workItemAge({ id: 'a', started: 9 }, 10), 2);
  assert.throws(() => workItemAge({ id: 'b', started: 1, finished: 4 }, 10), RangeError);
});

test('un ítem que termina antes de empezar es un error', () => {
  assert.throws(() => cycleTime({ id: 'x', started: 5, finished: 2 }), RangeError);
});

test('el WIP del tablero de ejemplo, día por día', () => {
  const wip = Array.from({ length: 10 }, (_, d) => wipOn(BOARD_HISTORY, d + 1));
  assert.deepEqual(wip, [2, 2, 3, 4, 4, 4, 4, 4, 4, 3]);
});

test('ley de Little: con la ventana vacía al inicio y al final, WIP = throughput × cycle time', () => {
  const finished = BOARD_HISTORY.filter((i) => i.finished !== undefined);
  const s = flowSummary(finished, 1, 10);
  assert.equal(s.throughput, 0.8);
  assert.equal(s.averageCycleTime, 4);
  assert.ok(Math.abs(s.averageWip - s.throughput * s.averageCycleTime) < 1e-9);
});

test('con un ítem todavía en curso al final, la igualdad ya no es exacta', () => {
  const avg = averageWip(BOARD_HISTORY, 1, 10);
  assert.ok(Math.abs(avg - 3.4) < 1e-9);
  assert.equal(throughput(BOARD_HISTORY, 1, 10), 0.8);
  assert.notEqual(avg, 0.8 * 4);
});

test('con el mismo throughput, más WIP significa más cycle time', () => {
  const cycleTimes: number[] = [];
  for (const limit of [4, 6, 10]) {
    const items = simulateBoard(limit, 2, 40);
    const window = items.filter((i) => i.finished !== undefined && i.finished >= 11 && i.finished <= 30);
    const avgCt = window.reduce((sum, i) => sum + cycleTime(i), 0) / window.length;
    assert.equal(throughput(items, 11, 30), 2);
    assert.equal(averageWip(items, 11, 30), limit);
    assert.equal(avgCt, limit / 2);
    cycleTimes.push(avgCt);
  }
  assert.deepEqual(cycleTimes, [2, 3, 5]);
});

test('la simulación nunca pasa el límite de WIP', () => {
  const items = simulateBoard(3, 1, 20);
  for (let day = 1; day <= 20; day++) assert.ok(wipOn(items, day) <= 3);
});
poker.test.tsDescargar poker.test.ts
// Pruebas de las reglas del planning poker del laboratorio (poker.ts).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { agreedEstimate, extremes, mustSplit, needsDiscussion, stepsBetween } from './poker.ts';

test('saltos en la escala 1, 2, 3, 5, 8, 13', () => {
  assert.equal(stepsBetween(3, 5), 1);
  assert.equal(stepsBetween(3, 8), 2);
  assert.equal(stepsBetween(2, 13), 4);
});

test('cartas a un salto o menos: no hace falta conversar', () => {
  assert.equal(needsDiscussion([3, 5, 5, 3]), false);
  assert.equal(needsDiscussion([8, 8, 8]), false);
});

test('cartas a más de un salto, o un "?": se conversa antes de volver a votar', () => {
  assert.equal(needsDiscussion([2, 3, 3, 8]), true);
  assert.equal(needsDiscussion([3, 3, '?']), true);
});

test('explican primero la carta más baja y la más alta', () => {
  assert.deepEqual(extremes([3, 2, 8, 3]), { low: 2, high: 8 });
  assert.equal(extremes(['?', '?']), undefined);
});

test('no se promedia: con 2 y 8 no hay estimación todavía', () => {
  assert.equal(agreedEstimate([2, 3, 3, 8]), undefined);
  assert.equal(agreedEstimate([3, 5, 5, 5]), 5);
  assert.equal(agreedEstimate([3, 3, 5, 5]), 5);
});

test('el ejemplo del apunte: 2, 3, 3, 8 y, después de conversar, 5, 5, 5, 8', () => {
  assert.equal(needsDiscussion([2, 3, 3, 8]), true);
  assert.deepEqual(extremes([2, 3, 3, 8]), { low: 2, high: 8 });
  assert.equal(needsDiscussion([5, 5, 5, 8]), false);
  assert.equal(agreedEstimate([5, 5, 5, 8]), 5);
});

test('13 o "?" obligan a dividir', () => {
  assert.equal(mustSplit(13), true);
  assert.equal(mustSplit('?'), true);
  assert.equal(mustSplit(8), false);
});
velocity.test.tsDescargar velocity.test.ts
// Pruebas de velocity, pronóstico, propuesta de sprint y burndown (velocity.ts).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PAST_VELOCITIES, SPRINT_3, SUMMER_CLASSES } from './examples.ts';
import { burndown, fillSprint, forecastSprints, idealLine, velocity } from './velocity.ts';

test('la velocity solo cuenta lo que cumple la Definition of Done', () => {
  assert.equal(velocity(SPRINT_3), 9);
  assert.equal(velocity([{ id: 'a', points: 8, done: false }]), 0);
});

test('el sprint 3 de ejemplo coincide con su velocity registrada', () => {
  assert.equal(velocity(SPRINT_3), PAST_VELOCITIES[2]);
});

test('el pronóstico es un rango: 21 puntos con velocities 8, 11 y 9 son de 2 a 3 sprints', () => {
  const f = forecastSprints(21, PAST_VELOCITIES);
  assert.equal(f.fewest, 2);
  assert.equal(f.most, 3);
  assert.ok(Math.abs(f.average - 28 / 3) < 1e-9);
});

test('sin sprints cerrados no hay pronóstico', () => {
  assert.throws(() => forecastSprints(10, []), RangeError);
});

test('las partes de Vacaciones útiles suman 21 puntos', () => {
  assert.equal(SUMMER_CLASSES.reduce((s, i) => s + i.points, 0), 21);
});

test('la propuesta de sprint respeta el orden y salta lo que no cabe', () => {
  const plan = fillSprint(SUMMER_CLASSES, 10);
  assert.equal(plan.points, 10);
  assert.deepEqual(
    plan.selected.map((i) => i.id.slice(0, 2)),
    ['V1', 'V2', 'V3', 'V4'],
  );
  assert.deepEqual(
    plan.left.map((i) => i.id.slice(0, 2)),
    ['V5', 'V6', 'V7'],
  );
});

test('burndown: un ítem sin terminar no baja la curva', () => {
  assert.deepEqual(burndown(10, [0, 2, 0, 1, 7]), [10, 10, 8, 8, 7, 0]);
  assert.deepEqual(burndown(10, [0, 2, 0, 1, 2]), [10, 10, 8, 8, 7, 5]);
  assert.deepEqual(idealLine(10, 5), [10, 8, 6, 4, 2, 0]);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 2: procesos, planificación y estimación. Se usa dentro del contenedor
# del curso.
#
#   make          revisa los tipos con tsc y el estilo con ESLint (no genera nada)
#   make test     hace lo mismo, corre las pruebas (*.test.ts) y los dos
#                 ejemplos del apunte
#   make demo     solo los ejemplos: node flow-demo.ts y node plan-demo.ts
#   make clean    borra build/
#
# backlog.md es el backlog de partida del laboratorio: los 15 pedidos que se
# convierten en issues de GitHub. backlog.test.ts revisa que tenga lo que pide
# la guía de la semana.

.PHONY: all test demo clean

all:
	tsc -p .
	eslint .

test: all
	node --test "*.test.ts"
	@$(MAKE) --no-print-directory demo

demo:
	node flow-demo.ts
	node plan-demo.ts

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-02",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"]
}