import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, TemplateRef, untracked, viewChild, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, PiDateRangeFieldComponent, PiEmptyStateComponent, PiLabelDirective, PiProgressBarComponent, PiSegmentedControlComponent, PiSelectFieldComponent, PiTextareaComponent, PiTextFieldComponent, type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { Sprint, SprintStatus, velocity, } from '@task-stream/web/tracker/domain'; import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart'; const DAY = 86_400_000; const BACKLOG = ''; /** `YYYY-MM-DD` of a local date (what the date picker gives). */ const isoDay = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const localDate = (day: string) => new Date(`${day}T00:00:00`); const label = (day: string, month: 'short' | 'long' = 'short') => localDate(day).toLocaleDateString('es', { day: 'numeric', month }); const STATUS: Record = { [SprintStatus.ACTIVE]: 'En curso', [SprintStatus.PLANNED]: 'Planificado', [SprintStatus.CLOSED]: 'Cerrado', }; /** /p/:key/sprints — plan the iterations, follow the one under way, close it. */ @Component({ selector: 'ts-sprints-page', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ RouterLink, SeriesChartComponent, PiActionButtonComponent, PiDateRangeFieldComponent, PiEmptyStateComponent, PiLabelDirective, PiProgressBarComponent, PiSegmentedControlComponent, PiSelectFieldComponent, PiTextareaComponent, PiTextFieldComponent, ], template: `

Iteraciones de unas semanas. Mete tickets desde su ficha; al cerrar un sprint, lo que quede pasa al siguiente o vuelve al backlog.

@if (project.canManage()) { }
@if (focus(); as sprint) {

{{ sprint.name }}

{{ statusLabel(sprint) }} {{ when(sprint) }}
@if (sprint.goal) {

🎯 {{ sprint.goal }}

} @if (project.burndown()?.sprintId === sprint.id) {
}
Ver en el tablero → @if (project.canManage()) { @switch (sprint.status) { @case ('ACTIVE') { } @case ('PLANNED') { } } }
} @if (planned().length) {

Planificados

} @if (closed().length) {

Cerrados

} @if (pace().history.length) {

Velocidad

Puntos terminados en cada sprint cerrado. @if (pace().average !== null) { Media de los últimos {{ paceWindow }}: {{ pace().average }} puntos por sprint. }

} @if (!project.sprints().length) { }
Nombre Fechas Objetivo

{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en este sprint como registro de lo hecho.

Lo pendiente va a
`, styles: ` .ts-sprint { display: grid; gap: 0.875rem; padding: 1rem 1.125rem; border-left: 3px solid var(--ts-line-strong); &[data-status='ACTIVE'] { border-left-color: var(--ts-brand); } &.is-focus { border-left-color: var(--ts-brand); } } .ts-sprint--focus { margin-bottom: 1.5rem; } .ts-sprint__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem; h2 { margin: 0; font-size: 1.25rem; } } .ts-sprint__goal { margin: 0; } .ts-sprint__chart { display: grid; gap: 0.5rem; pi-segmented-control { justify-self: end; } } .ts-sprint__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } .ts-sprints__title { margin: 1.25rem 0 0.625rem; font-size: 0.875rem; color: var(--ts-muted); text-transform: uppercase; letter-spacing: 0.04em; } .ts-sprints { display: grid; gap: 0.5rem; margin: 0; padding: 0; list-style: none; .ts-sprint { grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0.625rem 0.875rem; } } .ts-sprint__pick { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.875rem; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; &:hover strong { color: var(--ts-brand); } } .ts-sprint__row-actions { display: inline-flex; gap: 0.25rem; } .ts-velocity { display: grid; gap: 0.5rem; padding: 1rem 1.125rem; p { margin: 0; } } .ts-sprint__warn { color: var(--ts-c-amber, var(--ts-muted)); font-size: 0.8125rem; } .ts-pill[data-status='ACTIVE'] { --tone: var(--ts-brand); } `, }) export class SprintsPage { protected readonly project = inject(ProjectFacade); private readonly drawer = inject(DrawerService); private readonly sprintForm = viewChild.required>('sprintForm'); private readonly closeForm = viewChild.required>('closeForm'); /** The sprint picked from the lists; by default the one under way. */ private readonly picked = signal(null); protected readonly focus = computed(() => { const sprints = this.project.sprints(); return ( sprints.find((s) => s.id === this.picked()) ?? this.project.activeSprint() ?? sprints.find((s) => s.status === SprintStatus.PLANNED) ?? null ); }); protected readonly planned = computed(() => this.project.sprints().filter((s) => s.status === SprintStatus.PLANNED), ); protected readonly closed = computed(() => this.project.sprints().filter((s) => s.status === SprintStatus.CLOSED), ); /* Burndown */ protected readonly units = [ { value: 'tickets', label: 'Tickets' }, { value: 'points', label: 'Puntos' }, ]; protected readonly unit = signal('tickets'); protected readonly burndownLabels = computed( () => this.project.burndown()?.dates.map((d) => label(d)) ?? [], ); protected readonly burndownLongLabels = computed( () => this.project.burndown()?.dates.map((d) => label(d, 'long')) ?? [], ); protected readonly burndownSeries = computed(() => { const burndown = this.project.burndown(); if (!burndown) return []; const points = this.unit() === 'points'; const remaining = points ? burndown.remainingPoints : burndown.remaining; // The ideal line starts from what was open on the first day, in the unit shown. const first = remaining[0] ?? 0; const span = Math.max(burndown.dates.length - 1, 1); return [ { label: 'Ideal', values: burndown.dates.map( (_, i) => Math.round((first * (span - i) * 10) / span) / 10, ), color: 'var(--ts-muted)', dashed: true, }, { label: points ? 'Puntos pendientes' : 'Tickets pendientes', values: remaining, color: 'var(--ts-viz-1)', }, ]; }); protected readonly todayMarker = computed(() => { const index = this.project.burndown()?.dates.indexOf(isoDay(new Date())); return index === undefined || index === -1 ? null : { index, label: 'Hoy' }; }); protected readonly burndownSummary = computed(() => { const burndown = this.project.burndown(); const open = last(burndown?.remaining.filter((v) => v !== null) ?? []); return open === undefined ? 'Burndown del sprint' : `Burndown del sprint: quedan ${open} tickets por terminar`; }); /* Velocity */ protected readonly paceWindow = 3; protected readonly pace = computed(() => velocity(this.project.sprints(), this.paceWindow), ); protected readonly velocityLabels = computed(() => this.pace().history.map((h) => h.name), ); protected readonly velocitySeries = computed(() => [ { label: 'Comprometidos', values: this.pace().history.map((h) => h.committed), color: 'var(--ts-muted)', dashed: true, }, { label: 'Terminados', values: this.pace().history.map((h) => h.done), color: 'var(--ts-viz-1)', area: true, }, ]); protected readonly velocitySummary = computed(() => { const { history, average } = this.pace(); return `Velocidad de ${history.length} sprints cerrados${ average === null ? '' : `: media de ${average} puntos` }`; }); /** A planned sprint holding more than the team usually finishes. */ protected overCommitted = (sprint: Sprint) => { const average = this.pace().average; return average !== null && sprint.progress.storyPoints > average; }; /* Plan / edit */ protected readonly editing = signal(null); protected readonly name = signal(''); protected readonly goal = signal(''); protected readonly range = signal<{ start: Date | null; end: Date | null }>({ start: null, end: null, }); protected readonly canSave = computed( () => !!this.name().trim() && !!this.range().start && !!this.range().end, ); /* Close */ private closing: Sprint | null = null; protected readonly unfinished = signal(0); protected readonly carryOverTo = signal(BACKLOG); protected readonly carryOverOptions = computed[]>( () => [ { value: BACKLOG, label: 'El backlog' }, ...this.planned().map((s) => ({ value: s.id, label: `${s.name} (${label(s.startDate)})`, })), ], ); constructor() { // Load the burndown of whatever sprint is in focus. effect(() => { const sprint = this.focus(); if (sprint && sprint.status !== SprintStatus.PLANNED) untracked(() => this.project.loadBurndown(sprint.id)); }); } protected statusLabel = (sprint: Sprint) => STATUS[sprint.status]; protected isFocus = (sprint: Sprint) => this.focus()?.id === sprint.id; /** "12 oct – 23 oct · quedan 5 días". */ protected when(sprint: Sprint): string { const dates = `${label(sprint.startDate)} – ${label(sprint.endDate)}`; if (sprint.status !== SprintStatus.ACTIVE) return dates; const left = Math.ceil( (localDate(sprint.endDate).getTime() + DAY - Date.now()) / DAY, ); if (left < 0) return `${dates} · pasado de fecha`; return `${dates} · ${left === 1 ? 'queda 1 día' : `quedan ${left} días`}`; } protected progressLabel(sprint: Sprint): string { const { finished, total, finishedStoryPoints, storyPoints } = sprint.progress; const points = storyPoints ? ` · ${finishedStoryPoints} de ${storyPoints} puntos` : ''; return `${finished} de ${total} tickets terminados${points}`; } protected show(sprint: Sprint): void { this.picked.set(sprint.id); } protected openCreate(): void { this.editing.set(null); const lastEnd = last( this.project .sprints() .map((s) => s.endDate) .sort(), ); // Right after the last one, two weeks long. const start = lastEnd ? new Date(localDate(lastEnd).getTime() + DAY) : new Date(); this.name.set(`Sprint ${this.project.sprints().length + 1}`); this.goal.set(''); this.range.set({ start, end: new Date(start.getTime() + 13 * DAY), }); this.drawer.open('Planificar sprint', this.sprintForm()); } protected openEdit(sprint: Sprint): void { this.editing.set(sprint); this.name.set(sprint.name); this.goal.set(sprint.goal ?? ''); this.range.set({ start: localDate(sprint.startDate), end: localDate(sprint.endDate), }); this.drawer.open(`Editar ${sprint.name}`, this.sprintForm()); } protected save(): void { const { start, end } = this.range(); if (!this.canSave() || !start || !end) return; const details = { name: this.name().trim(), goal: this.goal().trim() || null, startDate: isoDay(start), endDate: isoDay(end), }; const sprint = this.editing(); if (sprint) this.project.updateSprint({ sprintId: sprint.id, ...details }); else this.project.createSprint(details); this.drawer.closeAll(); } protected openClose(sprint: Sprint): void { this.closing = sprint; this.unfinished.set(sprint.progress.total - sprint.progress.finished); this.carryOverTo.set(this.planned()[0]?.id ?? BACKLOG); this.drawer.open(`Cerrar ${sprint.name}`, this.closeForm()); } protected close(): void { if (!this.closing) return; this.project.closeSprint(this.closing.id, this.carryOverTo() || null); this.closing = null; this.drawer.closeAll(); } }