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>
148 lines
4.0 KiB
TypeScript
148 lines
4.0 KiB
TypeScript
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;
|
|
}
|