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:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View 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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',

View File

@@ -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