diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 3bafe39..c5bc855 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -37,6 +37,7 @@ describe('appRoutes', () => { 'epics', 'trash', 'releases', + 'report/:kind/:id', 'dashboard', 'analytics', 'settings', diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 95cc0e3..fac1806 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -618,3 +618,34 @@ html.dark-mode .pi-shell { 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; + } + } + } +} diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index cec6002..3e4345e 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -280,21 +280,22 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | -| `/explorar` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | -| `/p/:key/issues/:issueKey` | Ticket | -| `/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/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | -| `/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/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/activity` | Actividad en vivo | -| `/p/:key/members` | Miembros | +| Ruta | Página | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | +| `/explorar` | Proyectos públicos (sin sesión) | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | +| `/p/:key/issues/:issueKey` | Ticket | +| `/p/:key/releases` | Releases | +| `/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/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/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | +| `/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/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/activity` | Actividad en vivo | +| `/p/:key/members` | Miembros | 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 diff --git a/docs/10-futuro.md b/docs/10-futuro.md index be81b09..6a3a461 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -30,26 +30,23 @@ cuando hay pistas, por dónde empezar. ### 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 - para archivar. +En el panel del proyecto: -Y en el panel del proyecto: - -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 +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 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). -5. **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), +4. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP. +5. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), con semáforo. -7. **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 +6. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. +7. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de 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. -10. **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. -11. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por +8. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. +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. +10. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por navegador»). ### 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, transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya existían, en Sprints.) +- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 23c8582..8620e64 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index d377c3d..264bef5 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 4c81563..c631070 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -102,6 +102,7 @@ export interface IssueListFilter { assigneeId?: string; unassigned?: boolean; releaseId?: string; + sprintId?: string; /** Only tickets holding these custom values. */ customFields?: CustomFieldValue[]; limit: number; diff --git a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts index 76e3b59..693a1f3 100644 --- a/libs/web/tracker/presenters/src/lib/pages/releases.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/releases.page.ts @@ -115,6 +115,11 @@ const NO_RELEASE = ''; [queryParams]="{ release: release.id }" >Ver en la lista → + Informe para imprimir → @if (release.notes) { + 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.PLANNED]: 'Planificado', + [SprintStatus.ACTIVE]: 'En curso', + [SprintStatus.CLOSED]: 'Cerrado', +}; +const RELEASE_STATUS: Record = { + [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: ` +
+ ← Volver + +
+ + @if (!subject()) { + @if (project.project()) { + + } @else { + + } + } @else { + @let s = subject()!; +
+
+

+ {{ project.project()?.key }} · {{ project.project()?.name }} · + {{ kind() === 'sprint' ? 'Sprint' : 'Release' }} +

+

{{ s.name }}

+

{{ s.status }} · {{ s.dates }}

+ @if (s.about) { +

{{ s.about }}

+ } +
+ + @if (!list.loaded()) { + + } @else { +
+
+
Tickets
+
{{ totals().tickets }}
+
+
+
Terminados
+
{{ totals().done }} · {{ totals().percent }}%
+
+
+
Puntos
+
{{ totals().donePoints }} de {{ totals().points }}
+
+
+
Sin terminar
+
{{ totals().open }}
+
+
+ + @if (s.notes) { +
+

Notas de la versión

+ +
+ } + + @for (group of groups(); track group.category) { +
+

+ {{ group.label }} + {{ group.issues.length }} +

+ + + + + + + + + + + + + @for (issue of group.issues; track issue.id) { + + + + + + + + + } + +
ClaveTítuloTipoEstadoAsignadoPts
{{ issue.key }}{{ issue.title }}{{ typeOf(issue) }}{{ statusOf(issue) }}{{ issue.assignee?.name ?? '—' }} + {{ issue.storyPoints ?? '—' }} +
+
+ } @empty { +

Sin tickets.

+ } + + @if (list.total() > list.issues().length) { +

+ Se muestran {{ list.issues().length }} de {{ list.total() }}. +

+ } +
+ Generado el {{ today }} · TaskStream +
+ } +
+ } + `, + 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(); + 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(); + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts b/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts index 9bf0ee1..de12dad 100644 --- a/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/sprints.page.ts @@ -123,6 +123,11 @@ const STATUS: Record = { [queryParams]="{ sprint: sprint.id }" >Ver en el tablero → + Informe para imprimir → @if (project.canManage()) { @switch (sprint.status) { diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index 50383b9..3a332df 100644 --- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts +++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts @@ -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',