Files
task-stream/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
Josep Piguillem f45069a327 feat: a calendar to subscribe to, and the backlog's corners
- Each person can subscribe their dates in Google Calendar, Outlook or
  Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
  their unfinished tickets with a due date (assigned or followed), the
  planned and running sprints and the planned releases of their
  projects, as all-day events linking to the web. The token is made,
  renewed or dropped under /me/calendar (and in GraphQL); in the web,
  from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
  rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:48:33 +02:00

322 lines
9.5 KiB
TypeScript

import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
inject,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiCheckboxComponent,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSkeletonComponent,
} from '@pi/ui';
import {
BacklogFacade,
ProjectFacade,
provideBacklogFacade,
} from '@task-stream/web/tracker/application';
import {
BulkChange,
IssueSummary,
sumPoints,
} from '@task-stream/web/tracker/domain';
import { BulkBarComponent } from '../ui/bulk-bar';
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: [
PiCheckboxComponent,
BulkBarComponent,
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.issues().length) {
<span class="ts-muted ts-mono"
>{{ points() }} pts estimados · {{ unestimated() }} sin estimar</span
>
}
@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) {
@if (project.canContribute()) {
<ts-bulk-bar
[count]="selected().size"
[allSelected]="allSelected()"
(selectAll)="selectAll($event)"
(apply)="applyBulk($event)"
/>
}
<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"
[class.is-selected]="selected().has(issue.id)"
>
<pi-checkbox
class="ts-backlog__pick"
[disabled]="!project.canContribute()"
[checked]="selected().has(issue.id)"
(checkedChange)="toggle(issue.id, $event)"
><span class="visually-hidden"
>Seleccionar {{ issue.key }}</span
></pi-checkbox
>
<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__points ts-mono" title="Estimación">{{
issue.storyPoints ?? '—'
}}</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."
/>
}
<p class="ts-backlog__trash">
<a class="ts-plain-link" routerLink="../trash">Papelera</a>
</p>
`,
styles: `
.ts-backlog {
margin: 0;
padding: 0.25rem 0;
list-style: none;
/* Selected rows have a background: kept inside the rounded corners. */
overflow: hidden;
}
.ts-backlog__row {
display: grid;
grid-template-columns:
1rem 2rem auto auto minmax(0, 1fr)
auto auto 2rem 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: 1rem 2rem auto minmax(0, 1fr) auto;
.ts-backlog__type,
.ts-backlog__points,
.ts-pill,
.ts-backlog__priority,
.ts-backlog__who {
display: none;
}
}
}
.ts-backlog__row.is-selected {
background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface));
}
.ts-backlog__trash {
margin: 1rem 0 0;
font-size: 0.875rem;
text-align: right;
}
.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__points {
font-size: 0.8125rem;
text-align: right;
}
.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;
/* Bulk edit */
protected readonly selected = signal(new Set<string>());
protected readonly allSelected = computed(
() =>
this.facade.issues().length > 0 &&
this.facade.issues().every((i) => this.selected().has(i.id)),
);
protected toggle(issueId: string, on: boolean): void {
this.selected.update((set) => {
const next = new Set(set);
if (on) next.add(issueId);
else next.delete(issueId);
return next;
});
}
protected selectAll(on: boolean): void {
this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : []));
}
protected applyBulk(change: BulkChange): void {
this.facade.bulkUpdate([...this.selected()], change);
this.selected.set(new Set());
}
protected readonly points = computed(() => sumPoints(this.facade.issues()));
protected readonly unestimated = computed(
() => this.facade.issues().filter((i) => i.storyPoints === null).length,
);
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;
}