feat: an ordered backlog

A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -0,0 +1,232 @@
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSkeletonComponent,
} from '@pi/ui';
import {
BacklogFacade,
ProjectFacade,
provideBacklogFacade,
} from '@task-stream/web/tracker/application';
import { IssueSummary } from '@task-stream/web/tracker/domain';
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
@Component({
selector: 'ts-backlog-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
CdkDrag,
CdkDropList,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSkeletonComponent,
],
providers: [provideBacklogFacade()],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra
para reordenar; las columnas del tablero siguen este mismo orden.
</p>
@if (facade.loaded() && facade.total() > facade.issues().length) {
<span class="ts-muted ts-mono"
>{{ facade.issues().length }} de {{ facade.total() }}</span
>
}
</div>
@if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo cargar el backlog"
[text]="error"
/>
} @else if (!facade.loaded()) {
<pi-skeleton shape="text" [lines]="8" />
} @else if (facade.issues().length) {
<ol
class="ts-backlog ts-panel"
cdkDropList
[cdkDropListDisabled]="!project.canContribute()"
(cdkDropListDropped)="drop($event)"
>
@for (issue of facade.issues(); track issue.id; let i = $index) {
<li class="ts-backlog__row" cdkDrag [cdkDragData]="issue">
<span class="ts-backlog__pos ts-mono">{{ i + 1 }}</span>
<span
class="ts-backlog__type"
[style.--tone]="typeTone(issue)"
[title]="typeLabel(issue)"
>
<pi-icon [icon]="typeIcon(issue)" size="x-small" />
</span>
<a class="ts-key" [routerLink]="['..', 'issues', issue.key]">{{
issue.key
}}</a>
<a
class="ts-backlog__title"
[routerLink]="['..', 'issues', issue.key]"
>{{ issue.title }}</a
>
<span class="ts-pill" [style.--tone]="statusTone(issue)">{{
statusLabel(issue)
}}</span>
<span class="ts-backlog__priority ts-muted">{{
priorityLabel(issue)
}}</span>
<span
class="ts-backlog__who"
[title]="issue.assignee?.name ?? 'Sin asignar'"
>{{
issue.assignee ? initialsOf(issue.assignee.name) : '—'
}}</span
>
@if (project.canContribute()) {
<span class="ts-backlog__moves">
<pi-icon-button
icon="arrow-up"
color="grey"
appearance="ghost"
[attr.aria-label]="'Subir ' + issue.key"
[disabled]="i === 0"
(execute)="facade.move(issue.id, i - 1)"
/>
<pi-icon-button
icon="arrow-down"
color="grey"
appearance="ghost"
[attr.aria-label]="'Bajar ' + issue.key"
[disabled]="i === facade.issues().length - 1"
(execute)="facade.move(issue.id, i + 1)"
/>
</span>
}
</li>
}
</ol>
} @else {
<pi-empty-state
icon="task"
title="Nada pendiente de empezar"
description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge."
/>
}
`,
styles: `
.ts-backlog {
margin: 0;
padding: 0.25rem 0;
list-style: none;
}
.ts-backlog__row {
display: grid;
grid-template-columns: 2rem auto auto minmax(0, 1fr) auto auto auto auto;
gap: 0.625rem;
align-items: center;
padding: 0.375rem 0.875rem;
border-top: 1px solid var(--ts-line);
background: var(--ts-surface);
cursor: grab;
&:first-child {
border-top: 0;
}
@media (max-width: 720px) {
grid-template-columns: 2rem auto minmax(0, 1fr) auto;
.ts-backlog__type,
.ts-pill,
.ts-backlog__priority,
.ts-backlog__who {
display: none;
}
}
}
.ts-backlog__pos {
color: var(--ts-muted);
font-size: 0.75rem;
text-align: right;
}
.ts-backlog__type {
display: inline-flex;
color: var(--tone);
}
.ts-backlog__title {
overflow: hidden;
color: inherit;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--ts-brand);
}
}
.ts-backlog__priority {
font-size: 0.8125rem;
}
.ts-backlog__who {
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--ts-line);
font-size: 0.625rem;
font-weight: 600;
}
.ts-backlog__moves {
display: inline-flex;
}
.cdk-drag-preview {
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.cdk-drag-placeholder {
opacity: 0.35;
}
`,
})
export class BacklogPage {
protected readonly facade = inject(BacklogFacade);
protected readonly project = inject(ProjectFacade);
protected readonly initialsOf = initials;
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.facade.open(projectId));
});
}
protected drop(event: CdkDragDrop<unknown, unknown, IssueSummary>): void {
this.facade.move(event.item.data.id, event.currentIndex);
}
protected typeIcon = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).icon;
protected typeTone = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).tone;
protected typeLabel = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).label;
protected statusTone = (issue: IssueSummary) =>
statusLook(this.project.workflow(), issue.status).tone;
protected statusLabel = (issue: IssueSummary) =>
statusLook(this.project.workflow(), issue.status).label;
protected priorityLabel = (issue: IssueSummary) =>
PRIORITY[issue.priority].label;
}

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="backlog" routerLinkActive="is-active">Backlog</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>

View File

@@ -38,6 +38,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/issue.page').then((m) => m.IssuePage),
},
{
path: 'backlog',
title: 'Backlog · TaskStream',
loadComponent: () =>
import('./pages/backlog.page').then((m) => m.BacklogPage),
},
{
path: 'epics',
title: 'Épicas · TaskStream',