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:
@@ -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
|
||||
|
||||
414
libs/web/tracker/presenters/src/lib/pages/report.page.ts
Normal file
414
libs/web/tracker/presenters/src/lib/pages/report.page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user