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;
|
||||
}
|
||||
Reference in New Issue
Block a user