feat(web): printable report of a sprint or a release

/p/:key/report/(sprint|release)/🆔 what it is, its totals, the release
notes and its tickets by stage. «Imprimir o guardar en PDF» opens the
browser's dialog; on paper the app's header, tabs, tinted panels and
lights are left out. Linked from Sprints and Releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 10:51:40 +02:00
parent e5f4e6c683
commit 86e313efaa
11 changed files with 492 additions and 30 deletions

View File

@@ -37,6 +37,7 @@ describe('appRoutes', () => {
'epics', 'epics',
'trash', 'trash',
'releases', 'releases',
'report/:kind/:id',
'dashboard', 'dashboard',
'analytics', 'analytics',
'settings', 'settings',

View File

@@ -618,3 +618,34 @@ html.dark-mode .pi-shell {
background-color: var(--ts-surface-raised) !important; background-color: var(--ts-surface-raised) !important;
} }
} }
// Printing a report (/p/:key/report/…): the report alone, on white paper.
@media print {
body:has(ts-report-page) {
background: #fff !important;
color: #000;
.ts-header,
.ts-project-header,
.ts-tabs,
.ts-main::before,
.ts-main::after {
display: none !important;
}
.ts-main {
padding: 0;
}
// No tinted panels or soft lights on paper.
.ts-panel {
background: none !important;
box-shadow: none !important;
&::before,
&::after {
display: none !important;
}
}
}
}

View File

@@ -280,21 +280,22 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | | `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
| `/explorar` | Proyectos públicos (sin sesión) | | `/explorar` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
| `/p/:key/members` | Miembros | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -30,26 +30,23 @@ cuando hay pistas, por dónde empezar.
### En curso, en este orden ### En curso, en este orden
1. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o En el panel del proyecto:
para archivar.
Y en el panel del proyecto: 1. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
2. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
2. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
3. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
curso), cada grupo con enlace a la lista filtrada. curso), cada grupo con enlace a la lista filtrada.
4. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del 3. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
proyecto (p85 del tiempo de ciclo). proyecto (p85 del tiempo de ciclo).
5. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP. 4. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
6. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), 5. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo. con semáforo.
7. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. 6. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
8. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de 7. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
sobrecarga y sus bloqueados. sobrecarga y sus bloqueados.
9. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. 8. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
10. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días 9. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
siguientes a cada release. siguientes a cada release.
11. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por 10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»). navegador»).
### Aceptadas, para después de esta tanda ### Aceptadas, para después de esta tanda
@@ -129,3 +126,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados, - Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
existían, en Sprints.) existían, en Sprints.)
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.

Binary file not shown.

Binary file not shown.

View File

@@ -102,6 +102,7 @@ export interface IssueListFilter {
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;
releaseId?: string; releaseId?: string;
sprintId?: string;
/** Only tickets holding these custom values. */ /** Only tickets holding these custom values. */
customFields?: CustomFieldValue[]; customFields?: CustomFieldValue[];
limit: number; limit: number;

View File

@@ -115,6 +115,11 @@ const NO_RELEASE = '';
[queryParams]="{ release: release.id }" [queryParams]="{ release: release.id }"
>Ver en la lista →</a >Ver en la lista →</a
> >
<a
class="ts-release__link"
[routerLink]="['../report', 'release', release.id]"
>Informe para imprimir →</a
>
<span class="ts-spacer"></span> <span class="ts-spacer"></span>
@if (release.notes) { @if (release.notes) {
<pi-action-button <pi-action-button

View File

@@ -0,0 +1,414 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiNoteComponent, PiSkeletonComponent } from '@pi/ui';
import {
ListFacade,
ProjectFacade,
provideListFacade,
} from '@task-stream/web/tracker/application';
import {
IssueSummary,
ReleaseStatus,
SprintStatus,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { MarkdownComponent } from '../ui/markdown';
import { CATEGORY, statusLook, typeLook } from '../ui/copy';
/** Groups in the order work goes through them. */
const ORDER: StatusCategory[] = [
StatusCategory.DONE,
StatusCategory.QA,
StatusCategory.IN_PROGRESS,
StatusCategory.TODO,
StatusCategory.CANCELLED,
];
const day = (iso: string | null) =>
iso
? new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
})
: '—';
const SPRINT_STATUS: Record<SprintStatus, string> = {
[SprintStatus.PLANNED]: 'Planificado',
[SprintStatus.ACTIVE]: 'En curso',
[SprintStatus.CLOSED]: 'Cerrado',
};
const RELEASE_STATUS: Record<ReleaseStatus, string> = {
[ReleaseStatus.PLANNED]: 'Planificada',
[ReleaseStatus.RELEASED]: 'Publicada',
[ReleaseStatus.ARCHIVED]: 'Archivada',
};
/**
* /p/:key/report/(sprint|release)/:id — a sprint or a release on paper:
* what it is, how far it got and its tickets by stage (and, for a release,
* its notes). «Imprimir» uses the browser's dialog, which also saves a PDF;
* the app's header and tabs stay off the page (styles.scss).
*/
@Component({
selector: 'ts-report-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiNoteComponent,
PiSkeletonComponent,
MarkdownComponent,
],
providers: [provideListFacade()],
template: `
<div class="ts-report__toolbar">
<a class="ts-plain-link" [routerLink]="['../../..', back()]">← Volver</a>
<button type="button" class="ts-primary" (click)="print()">
Imprimir o guardar en PDF
</button>
</div>
@if (!subject()) {
@if (project.project()) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No existe"
[text]="
kind() === 'sprint'
? 'Ese sprint no está en este proyecto.'
: 'Esa release no está en este proyecto.'
"
/>
} @else {
<pi-skeleton shape="block" height="16rem" />
}
} @else {
@let s = subject()!;
<article class="ts-report ts-panel">
<header class="ts-report__head">
<p class="ts-report__eyebrow">
{{ project.project()?.key }} · {{ project.project()?.name }} ·
{{ kind() === 'sprint' ? 'Sprint' : 'Release' }}
</p>
<h1>{{ s.name }}</h1>
<p class="ts-report__meta">{{ s.status }} · {{ s.dates }}</p>
@if (s.about) {
<p class="ts-report__about">{{ s.about }}</p>
}
</header>
@if (!list.loaded()) {
<pi-skeleton shape="block" height="8rem" />
} @else {
<dl class="ts-report__totals">
<div>
<dt>Tickets</dt>
<dd>{{ totals().tickets }}</dd>
</div>
<div>
<dt>Terminados</dt>
<dd>{{ totals().done }} · {{ totals().percent }}%</dd>
</div>
<div>
<dt>Puntos</dt>
<dd>{{ totals().donePoints }} de {{ totals().points }}</dd>
</div>
<div>
<dt>Sin terminar</dt>
<dd>{{ totals().open }}</dd>
</div>
</dl>
@if (s.notes) {
<section class="ts-report__notes">
<h2>Notas de la versión</h2>
<ts-markdown [text]="s.notes" />
</section>
}
@for (group of groups(); track group.category) {
<section class="ts-report__group">
<h2>
{{ group.label }}
<span class="ts-report__count">{{ group.issues.length }}</span>
</h2>
<table>
<thead>
<tr>
<th>Clave</th>
<th>Título</th>
<th>Tipo</th>
<th>Estado</th>
<th>Asignado</th>
<th class="ts-report__num">Pts</th>
</tr>
</thead>
<tbody>
@for (issue of group.issues; track issue.id) {
<tr>
<td class="ts-report__key">{{ issue.key }}</td>
<td>{{ issue.title }}</td>
<td>{{ typeOf(issue) }}</td>
<td>{{ statusOf(issue) }}</td>
<td>{{ issue.assignee?.name ?? '—' }}</td>
<td class="ts-report__num">
{{ issue.storyPoints ?? '—' }}
</td>
</tr>
}
</tbody>
</table>
</section>
} @empty {
<p class="ts-muted">Sin tickets.</p>
}
@if (list.total() > list.issues().length) {
<p class="ts-muted">
Se muestran {{ list.issues().length }} de {{ list.total() }}.
</p>
}
<footer class="ts-report__foot">
Generado el {{ today }} · TaskStream
</footer>
}
</article>
}
`,
styles: `
:host {
display: grid;
gap: 1rem;
}
.ts-report__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.ts-report {
display: grid;
gap: 1.25rem;
padding: 1.5rem;
}
.ts-report__eyebrow {
margin: 0;
color: var(--ts-muted);
font-size: 0.8125rem;
}
h1 {
margin: 0.25rem 0;
font-size: 1.75rem;
}
.ts-report__meta,
.ts-report__about {
margin: 0;
color: var(--ts-ink-soft);
}
.ts-report__about {
margin-top: 0.5rem;
white-space: pre-line;
}
.ts-report__totals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
div {
padding: 0.625rem 0.75rem;
border: 1px solid var(--ts-line);
border-radius: 0.625rem;
}
dt {
color: var(--ts-muted);
font-size: 0.75rem;
}
dd {
margin: 0.125rem 0 0;
font-size: 1.125rem;
font-weight: 600;
}
}
h2 {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0 0 0.5rem;
font-size: 1rem;
}
.ts-report__count {
color: var(--ts-muted);
font-size: 0.8125rem;
font-weight: 400;
}
.ts-report__group {
break-inside: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
th {
color: var(--ts-muted);
font-weight: 500;
text-align: left;
}
th,
td {
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ts-line);
vertical-align: top;
}
tr {
break-inside: avoid;
}
.ts-report__key {
font-family: var(--ts-font-mono, monospace);
white-space: nowrap;
}
.ts-report__num {
text-align: right;
}
.ts-report__foot {
color: var(--ts-muted);
font-size: 0.75rem;
}
@media (max-width: 640px) {
.ts-report__totals {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ts-report {
padding: 1rem;
overflow-x: auto;
}
}
@media print {
.ts-report__toolbar {
display: none;
}
.ts-report {
padding: 0;
border: 0;
box-shadow: none;
background: none;
color: #000;
}
}
`,
})
export class ReportPage {
readonly kind = input<'sprint' | 'release'>('sprint');
readonly id = input.required<string>();
protected readonly project = inject(ProjectFacade);
protected readonly list = inject(ListFacade);
protected readonly today = day(new Date().toISOString());
/** The page it came from, to go back to. */
protected readonly back = computed(() =>
this.kind() === 'sprint' ? 'sprints' : 'releases',
);
/** What the report is about, told the same way for both. */
protected readonly subject = computed(() => {
if (this.kind() === 'sprint') {
const sprint = this.project.sprints().find((s) => s.id === this.id());
return sprint
? {
name: sprint.name,
status: SPRINT_STATUS[sprint.status],
dates: `del ${day(sprint.startDate)} al ${day(sprint.endDate)}`,
about: sprint.goal,
notes: null,
}
: null;
}
const release = this.project.releases().find((r) => r.id === this.id());
return release
? {
name: release.name,
status: RELEASE_STATUS[release.status],
dates: release.releasedAt
? `publicada el ${day(release.releasedAt)}`
: release.targetDate
? `prevista para el ${day(release.targetDate)}`
: 'sin fecha',
about: release.description,
notes: release.notes,
}
: null;
});
protected readonly totals = computed(() => {
const issues = this.list.issues();
const done = issues.filter((i) => i.statusCategory === StatusCategory.DONE);
const open = issues.filter(
(i) =>
i.statusCategory !== StatusCategory.DONE &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const points = (list: IssueSummary[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
const counted = issues.filter(
(i) => i.statusCategory !== StatusCategory.CANCELLED,
);
return {
tickets: issues.length,
done: done.length,
open: open.length,
percent: counted.length
? Math.round((done.length / counted.length) * 100)
: 0,
points: points(counted),
donePoints: points(done),
};
});
protected readonly groups = computed(() =>
ORDER.map((category) => ({
category,
label: CATEGORY[category].label,
issues: this.list
.issues()
.filter((i) => i.statusCategory === category)
.sort((a, b) => a.rank.localeCompare(b.rank)),
})).filter((g) => g.issues.length),
);
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.list.open(projectId));
});
effect(() => {
const id = this.id();
const criteria =
this.kind() === 'sprint' ? { sprintId: id } : { releaseId: id };
untracked(() => this.list.filter(criteria));
});
}
protected typeOf(issue: IssueSummary): string {
return typeLook(this.project.workflow(), issue.type).label;
}
protected statusOf(issue: IssueSummary): string {
return statusLook(this.project.workflow(), issue.status).label;
}
protected print(): void {
window.print();
}
}

View File

@@ -123,6 +123,11 @@ const STATUS: Record<SprintStatus, string> = {
[queryParams]="{ sprint: sprint.id }" [queryParams]="{ sprint: sprint.id }"
>Ver en el tablero →</a >Ver en el tablero →</a
> >
<a
class="ts-plain-link"
[routerLink]="['../report', 'sprint', sprint.id]"
>Informe para imprimir →</a
>
<span class="ts-spacer"></span> <span class="ts-spacer"></span>
@if (project.canManage()) { @if (project.canManage()) {
@switch (sprint.status) { @switch (sprint.status) {

View File

@@ -92,6 +92,12 @@ export const trackerRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./pages/releases.page').then((m) => m.ReleasesPage), import('./pages/releases.page').then((m) => m.ReleasesPage),
}, },
{
path: 'report/:kind/:id',
title: 'Informe · TaskStream',
loadComponent: () =>
import('./pages/report.page').then((m) => m.ReportPage),
},
{ {
path: 'dashboard', path: 'dashboard',
title: 'Seguimiento · TaskStream', title: 'Seguimiento · TaskStream',