feat: per-project workflow settings, statistics and "my work" home

Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -36,16 +36,12 @@ import {
} from '@task-stream/web/tracker/application';
import {
BoardColumn,
IssueArea,
IssuePriority,
IssueStatus,
IssueSummary,
IssueType,
REASON_REQUIRED,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card';
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
import { options, PRIORITY, statusLook } from '../ui/copy';
const ALL = '';
@@ -85,7 +81,7 @@ const ALL = '';
<pi-select-field
class="ts-filter"
placeholder="Área"
[options]="areaOptions"
[options]="areaFilterOptions()"
[(value)]="area"
/>
<span class="ts-board-total"
@@ -127,10 +123,16 @@ const ALL = '';
[id]="column.status"
[cdkDropListData]="column"
[cdkDropListDisabled]="!project.canContribute()"
[cdkDropListEnterPredicate]="canEnter"
(cdkDropListDropped)="drop($event)"
>
@for (issue of column.issues; track issue.id) {
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
<ts-issue-card
cdkDrag
[cdkDragData]="issue"
[issue]="issue"
[workflow]="project.workflow()"
/>
} @empty {
<p class="ts-column__empty">Nada por aquí</p>
}
@@ -146,11 +148,17 @@ const ALL = '';
(submit)="$event.preventDefault(); create()"
novalidate
>
<pi-segmented-control
ariaLabel="Tipo"
[options]="typeOptions"
[(value)]="newType"
/>
@if (typeOptions().length <= 5) {
<pi-segmented-control
ariaLabel="Tipo"
[options]="typeOptions()"
[(value)]="newType"
/>
} @else {
<pi-select-field [options]="typeOptions()" [(value)]="newType">
<pi-label>Tipo</pi-label>
</pi-select-field>
}
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
<pi-label [isRequired]="true">Título</pi-label>
</pi-text-field>
@@ -160,7 +168,7 @@ const ALL = '';
>
<pi-label>Descripción</pi-label>
</pi-textarea>
@if (newType() === 'UI_CHANGE') {
@if (project.workflow().isDesignType(newType())) {
<pi-text-field
[formField]="f.figmaUrl"
placeholder="https://www.figma.com/design/…"
@@ -172,7 +180,7 @@ const ALL = '';
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
<pi-label>Prioridad</pi-label>
</pi-select-field>
<pi-select-field [formField]="f.area" [options]="areaOptions">
<pi-select-field [formField]="f.area" [options]="areaOptions()">
<pi-label>Área</pi-label>
</pi-select-field>
</div>
@@ -262,16 +270,35 @@ export class BoardPage {
.map((r) => ({ value: r.id, label: r.name })),
],
);
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
/** Archived areas still filter (old tickets keep them). */
protected readonly areaFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todas las áreas' },
...options(AREA),
];
...this.project
.workflow()
.settings.areas.map((a) => ({ value: a.id, label: a.name })),
]);
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Sin área' },
...this.project
.workflow()
.activeAreas()
.map((a) => ({ value: a.id, label: a.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
options(TYPE);
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
() =>
this.project
.workflow()
.activeTypes()
.map((t) => ({ value: t.id, label: t.name })),
);
/* New ticket */
protected readonly newType = signal<IssueType>(IssueType.BUG);
protected readonly newType = signal<string>('');
private readonly model = signal({
title: '',
description: '',
@@ -286,13 +313,13 @@ export class BoardPage {
maxLength(p.title, 255);
});
protected readonly titlePlaceholder = computed(() =>
this.newType() === IssueType.BUG
this.project.workflow().type(this.newType()).icon === 'bug'
? 'El botón de pagar no responde en Safari'
: 'Qué hay que hacer',
);
/* Move needing a reason */
private pendingMove: { issueId: string; to: IssueStatus } | null = null;
private pendingMove: { issueId: string; to: string } | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
@@ -311,15 +338,27 @@ export class BoardPage {
const filter = {
assigneeId: this.assigneeFilter() || undefined,
releaseId: this.releaseFilter() || undefined,
area: (this.area() || undefined) as IssueArea | undefined,
area: this.area() || undefined,
};
if (!projectId) return;
untracked(() => this.board.show({ projectId, ...filter }));
});
}
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
protected statusLabel = (status: string) =>
statusLook(this.project.workflow(), status).label;
protected statusTone = (status: string) =>
statusLook(this.project.workflow(), status).tone;
/** Columns a dragged ticket can enter: its allowed transitions. */
protected readonly canEnter = (
drag: CdkDrag<IssueSummary>,
drop: CdkDropList<BoardColumn>,
) =>
this.project
.workflow()
.allowedFrom(drag.data.status)
.includes(drop.data.status);
protected drop(
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
@@ -327,10 +366,10 @@ export class BoardPage {
if (event.previousContainer === event.container) return;
const to = event.container.data.status;
const issueId = event.item.data.id;
if (REASON_REQUIRED.includes(to)) {
if (this.project.workflow().requiresReason(to)) {
this.pendingMove = { issueId, to };
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm());
return;
}
this.board.moveIssue(issueId, to);
@@ -345,6 +384,9 @@ export class BoardPage {
}
protected openCreate(): void {
const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType()))
this.newType.set(types[0]?.value ?? '');
this.model.set({
title: '',
description: '',
@@ -367,11 +409,11 @@ export class BoardPage {
title: v.title,
description: v.description || null,
figmaUrl:
this.newType() === IssueType.UI_CHANGE && v.figmaUrl
this.project.workflow().isDesignType(this.newType()) && v.figmaUrl
? v.figmaUrl
: null,
priority: v.priority as IssuePriority,
area: (v.area || null) as IssueArea | null,
area: v.area || null,
assigneeId: v.assigneeId || null,
releaseId: v.releaseId || null,
});