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

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