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

@@ -8,12 +8,11 @@ import { RouterLink } from '@angular/router';
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
BOARD_COLUMNS,
IssueStatus,
IssueType,
ProjectRole,
QA_FAILED,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { initials, ROLE, ROLE_TONE, STATUS, TYPE } from '../ui/copy';
import { initials, ROLE, ROLE_TONE, statusLook, typeLook } from '../ui/copy';
/** "Seguimiento": the state of the whole project at a glance (live). */
@Component({
@@ -366,12 +365,20 @@ export class DashboardPage {
protected readonly facade = inject(ProjectFacade);
protected readonly initialsOf = initials;
private count(status: IssueStatus): number {
private count(status: string): number {
return (
this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0
);
}
/** Tickets in statuses of a category (whatever the project calls them). */
private countCategory(category: StatusCategory): number {
const workflow = this.facade.workflow();
return (this.facade.stats()?.byStatus ?? [])
.filter((row) => workflow.status(row.key).category === category)
.reduce((sum, row) => sum + row.count, 0);
}
protected readonly kpis = computed<
{
label: string;
@@ -382,7 +389,11 @@ export class DashboardPage {
>(() => {
const stats = this.facade.stats();
if (!stats) return [];
const bugs = stats.byType.find((t) => t.key === IssueType.BUG)?.count ?? 0;
// Bugs: every type drawn with the bug icon.
const workflow = this.facade.workflow();
const bugs = stats.byType
.filter((t) => workflow.type(t.key).icon === 'bug')
.reduce((sum, t) => sum + t.count, 0);
return [
{ label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' },
{
@@ -393,36 +404,50 @@ export class DashboardPage {
},
{ label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' },
{
label: 'Pendientes de QA',
value:
this.count(IssueStatus.READY_FOR_QA) + this.count(IssueStatus.IN_QA),
label: 'En QA',
value: this.countCategory(StatusCategory.QA),
icon: 'qa',
color: 'highlighted',
},
{
label: 'QA fallidos',
value: this.count(IssueStatus.QA_FAILED),
value: this.count(QA_FAILED),
icon: 'exclamation',
color: 'warn',
},
{
label: 'Hechos',
value: this.count(IssueStatus.DONE),
value: this.countCategory(StatusCategory.DONE),
icon: 'check-rounded',
color: 'success',
},
];
});
/** Statuses in workflow order (cancelled at the end). */
protected readonly flow = computed(() =>
[...BOARD_COLUMNS, IssueStatus.CANCELLED].map((status) => ({
status,
label: STATUS[status].label,
tone: STATUS[status].tone,
count: this.count(status),
})),
);
/**
* The project's statuses in board order, cancelled ones last. Archived (or
* no longer configured) statuses only while tickets still sit in them.
*/
protected readonly flow = computed(() => {
const workflow = this.facade.workflow();
const configured = workflow.settings.statuses.map((s) => s.id);
const ids = [
...configured,
...(this.facade.stats()?.byStatus ?? [])
.map((row) => row.key)
.filter((key) => !configured.includes(key)),
];
const cancelled = (id: string) =>
workflow.status(id).category === StatusCategory.CANCELLED;
return [...ids.filter((id) => !cancelled(id)), ...ids.filter(cancelled)]
.map((status) => ({
status,
...statusLook(workflow, status),
archived: workflow.status(status).archived,
count: this.count(status),
}))
.filter((row) => !row.archived || row.count > 0);
});
protected readonly flowLabel = computed(() =>
this.flow()
.filter((s) => s.count)
@@ -471,5 +496,5 @@ export class DashboardPage {
protected readonly roleLabel = (role: ProjectRole) => ROLE[role];
protected readonly roleTone = (role: ProjectRole) => ROLE_TONE[role];
protected readonly typeOf = (key: string) =>
TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' };
typeLook(this.facade.workflow(), key);
}