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

@@ -115,6 +115,11 @@ const NO_RELEASE = '';
[queryParams]="{ release: release.id }"
>Ver en la lista →</a
>
<a
class="ts-release__link"
[routerLink]="['../report', 'release', release.id]"
>Informe para imprimir →</a
>
<span class="ts-spacer"></span>
@if (release.notes) {
<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 }"
>Ver en el tablero →</a
>
<a
class="ts-plain-link"
[routerLink]="['../report', 'sprint', sprint.id]"
>Informe para imprimir →</a
>
<span class="ts-spacer"></span>
@if (project.canManage()) {
@switch (sprint.status) {

View File

@@ -92,6 +92,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
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',
title: 'Seguimiento · TaskStream',