feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSkeletonComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
EpicsFacade,
|
||||
ProjectFacade,
|
||||
provideEpicsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { Epic } from '@task-stream/web/tracker/domain';
|
||||
import { statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** /p/:key/epics — the big pieces of work and how far each has got. */
|
||||
@Component({
|
||||
selector: 'ts-epics-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiEmptyStateComponent,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSkeletonComponent,
|
||||
],
|
||||
providers: [provideEpicsFacade()],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Una épica es un ticket que agrupa a otros. Cuelga tickets de ella desde
|
||||
su ficha («Subtareas») y aquí verás cuánto falta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (facade.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudieron cargar las épicas"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else if (!facade.loaded()) {
|
||||
<pi-skeleton shape="text" [lines]="6" />
|
||||
} @else if (facade.epics().length) {
|
||||
<ul class="ts-epics">
|
||||
@for (epic of facade.epics(); track epic.issue.id) {
|
||||
<li class="ts-epic ts-panel" [style.--tone]="typeTone(epic)">
|
||||
<header class="ts-epic__head">
|
||||
<a
|
||||
class="ts-key"
|
||||
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||
>{{ epic.issue.key }}</a
|
||||
>
|
||||
<a
|
||||
class="ts-epic__title"
|
||||
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||
>{{ epic.issue.title }}</a
|
||||
>
|
||||
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||
statusLabel(epic)
|
||||
}}</span>
|
||||
</header>
|
||||
<pi-progress-bar
|
||||
[label]="summary(epic)"
|
||||
[value]="epic.progress.percent"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<pi-empty-state
|
||||
icon="flag"
|
||||
title="Aún no hay épicas"
|
||||
description="Abre un ticket grande y crea sus subtareas desde «Subtareas»: aparecerá aquí con su progreso."
|
||||
/>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-epics {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-epic {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.125rem;
|
||||
border-left: 3px solid var(--tone, var(--ts-brand));
|
||||
}
|
||||
.ts-epic__head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ts-epic__title {
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class EpicsPage {
|
||||
protected readonly facade = inject(EpicsFacade);
|
||||
private readonly project = inject(ProjectFacade);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (projectId) untracked(() => this.facade.open(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected summary(epic: Epic): string {
|
||||
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
||||
epic.progress;
|
||||
const points = storyPoints
|
||||
? ` · ${finishedStoryPoints}/${storyPoints} puntos`
|
||||
: '';
|
||||
return `${percent}% · ${finished} de ${total} tickets terminados${points}`;
|
||||
}
|
||||
|
||||
protected typeTone = (epic: Epic) =>
|
||||
typeLook(this.project.workflow(), epic.issue.type).tone;
|
||||
protected statusTone = (epic: Epic) =>
|
||||
statusLook(this.project.workflow(), epic.issue.status).tone;
|
||||
protected statusLabel = (epic: Epic) =>
|
||||
statusLook(this.project.workflow(), epic.issue.status).label;
|
||||
}
|
||||
@@ -192,6 +192,92 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
.ts-inline-form--two {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
// Breakdown: the ticket it hangs from, its checklist and its subtasks.
|
||||
.ts-parent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-parent__title {
|
||||
overflow: hidden;
|
||||
max-width: 18rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-checklist,
|
||||
.ts-children {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ts-checklist__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
|
||||
&.is-done {
|
||||
color: var(--ts-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-children {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ts-child {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
gap: 0.625rem;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
|
||||
.ts-comment__avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-child__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-parent-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.ts-inline-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .ts-muted {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Comments: a thread with initials on a rail.
|
||||
.ts-comment {
|
||||
display: grid;
|
||||
|
||||
@@ -14,11 +14,13 @@ import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
@@ -68,11 +70,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
@@ -96,6 +100,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||
status().label
|
||||
}}</span>
|
||||
@if (issue.parent; as parent) {
|
||||
<a
|
||||
class="ts-parent"
|
||||
[routerLink]="['..', parent.key]"
|
||||
[title]="'Cuelga de ' + parent.key"
|
||||
>
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span class="ts-key">{{ parent.key }}</span>
|
||||
<span class="ts-parent__title">{{ parent.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
@@ -161,6 +176,168 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Checklist
|
||||
@if (issue.checklist.length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.checklistProgress().done }}/{{
|
||||
facade.checklistProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (issue.checklist.length) {
|
||||
<ul class="ts-checklist">
|
||||
@for (item of issue.checklist; track item.id) {
|
||||
<li class="ts-checklist__item" [class.is-done]="item.done">
|
||||
<pi-checkbox
|
||||
[label]="item.text"
|
||||
[checked]="item.done"
|
||||
[disabled]="!project.canContribute()"
|
||||
(checkedChange)="
|
||||
facade.updateChecklistItem(item.id, { done: $event })
|
||||
"
|
||||
/>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="close"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="'Quitar «' + item.text + '»'"
|
||||
(execute)="facade.removeChecklistItem(item.id)"
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin pasos.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); addChecklistItem()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Nuevo paso, p. ej. Actualizar el changelog"
|
||||
[(value)]="checklistText"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir"
|
||||
[disabled]="!checklistText().trim()"
|
||||
(execute)="addChecklistItem()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Subtareas
|
||||
@if (facade.children().length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.childProgress().finished }}/{{
|
||||
facade.childProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (facade.children().length) {
|
||||
<pi-progress-bar
|
||||
[label]="facade.childProgress().percent + '% terminado'"
|
||||
[value]="facade.childProgress().percent"
|
||||
/>
|
||||
<ul class="ts-children">
|
||||
@for (child of facade.children(); track child.id) {
|
||||
<li class="ts-child">
|
||||
<a class="ts-key" [routerLink]="['..', child.key]">{{
|
||||
child.key
|
||||
}}</a>
|
||||
<span class="ts-child__title">{{ child.title }}</span>
|
||||
<span
|
||||
class="ts-pill"
|
||||
[style.--tone]="statusTone(child.status)"
|
||||
>{{ statusLabel(child.status) }}</span
|
||||
>
|
||||
@if (child.assignee) {
|
||||
<span
|
||||
class="ts-comment__avatar"
|
||||
[title]="child.assignee.name"
|
||||
>{{ initialsOf(child.assignee.name) }}</span
|
||||
>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin subtareas.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form"
|
||||
(submit)="$event.preventDefault(); addSubtask()"
|
||||
novalidate
|
||||
>
|
||||
<pi-select-field
|
||||
[options]="subtaskTypeOptions()"
|
||||
[value]="subtaskType() ?? defaultSubtaskType()"
|
||||
(valueChange)="subtaskType.set($event)"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Título de la subtarea"
|
||||
[(value)]="subtaskTitle"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Crear"
|
||||
[disabled]="!subtaskTitle().trim()"
|
||||
(execute)="addSubtask()"
|
||||
/>
|
||||
</form>
|
||||
<div class="ts-parent-form">
|
||||
@if (issue.parent; as parent) {
|
||||
<span class="ts-muted"
|
||||
>Cuelga de
|
||||
<a class="ts-key" [routerLink]="['..', parent.key]">{{
|
||||
parent.key
|
||||
}}</a></span
|
||||
>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Sacar"
|
||||
(execute)="facade.setParent(null)"
|
||||
/>
|
||||
} @else {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); setParent()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||
[(value)]="parentKey"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Colgar"
|
||||
[disabled]="!parentKey().trim()"
|
||||
(execute)="setParent()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Adjuntos
|
||||
@@ -639,6 +816,23 @@ export class IssuePage {
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
protected readonly parentKey = signal('');
|
||||
/** Active types; subtasks start as tasks when the project has them. */
|
||||
protected readonly subtaskTypeOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.issueTypes.filter((t) => !t.archived)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
);
|
||||
protected readonly defaultSubtaskType = computed(() => {
|
||||
const ids = this.subtaskTypeOptions().map((o) => o.value);
|
||||
return ids.includes('TASK') ? 'TASK' : (ids[0] ?? '');
|
||||
});
|
||||
protected readonly pendingFiles = signal<File[]>([]);
|
||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||
|
||||
@@ -687,6 +881,28 @@ export class IssuePage {
|
||||
? LINK[link.type].outward
|
||||
: LINK[link.type].inward;
|
||||
|
||||
protected addChecklistItem(): void {
|
||||
const text = this.checklistText().trim();
|
||||
if (!text) return;
|
||||
this.facade.addChecklistItem(text);
|
||||
this.checklistText.set('');
|
||||
}
|
||||
|
||||
protected addSubtask(): void {
|
||||
const title = this.subtaskTitle().trim();
|
||||
const type = this.subtaskType() ?? this.defaultSubtaskType();
|
||||
if (!title || !type) return;
|
||||
this.facade.addSubtask(title, type);
|
||||
this.subtaskTitle.set('');
|
||||
}
|
||||
|
||||
protected setParent(): void {
|
||||
const key = this.parentKey().trim();
|
||||
if (!key) return;
|
||||
this.facade.setParent(key);
|
||||
this.parentKey.set('');
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingStatus = to;
|
||||
|
||||
@@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
}
|
||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
||||
|
||||
@@ -38,6 +38,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/issue.page').then((m) => m.IssuePage),
|
||||
},
|
||||
{
|
||||
path: 'epics',
|
||||
title: 'Épicas · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/epics.page').then((m) => m.EpicsPage),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
title: 'Releases · TaskStream',
|
||||
|
||||
@@ -40,6 +40,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
||||
>
|
||||
<i></i><i></i><i></i><i></i>
|
||||
</span>
|
||||
@if (issue().parent; as parent) {
|
||||
<span class="ts-card__meta" [attr.title]="'Cuelga de ' + parent.title"
|
||||
>↑ {{ parent.key }}</span
|
||||
>
|
||||
}
|
||||
@if (issue().release; as release) {
|
||||
<span class="ts-card__meta" title="Release"
|
||||
>◆ {{ release.name }}</span
|
||||
|
||||
Reference in New Issue
Block a user