- 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>
322 lines
9.5 KiB
TypeScript
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;
|
|
}
|