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

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
@@ -25,7 +26,10 @@ import {
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import { AuthFacade } from '@task-stream/web/identity/application';
import { AvatarsComponent } from '../ui/avatars';
import { GoToComponent } from '../ui/go-to';
import { MyWorkComponent } from './my-work';
import { projectTone, timeAgo } from '../ui/copy';
@Component({
@@ -35,6 +39,8 @@ import { projectTone, timeAgo } from '../ui/copy';
RouterLink,
FormField,
AvatarsComponent,
GoToComponent,
MyWorkComponent,
PiActionButtonComponent,
PiEmptyStateComponent,
PiSkeletonComponent,
@@ -45,20 +51,35 @@ import { projectTone, timeAgo } from '../ui/copy';
template: `
<header class="ts-page-header">
<div>
<p class="ts-eyebrow">
Tus proyectos ·
<span class="ts-mono">{{ pad(facade.projects().length) }}</span>
</p>
<h1>Proyectos</h1>
<p>Los proyectos de los que formas parte, al día en tiempo real.</p>
<p class="ts-eyebrow">Pireon · TaskStream</p>
<h1>
Hola,
<span class="ts-gradient-text">{{ firstName() }}</span>
</h1>
<p>Lo tuyo en todos tus proyectos, al día en tiempo real.</p>
</div>
<div class="ts-home-actions">
<ts-go-to />
<pi-action-button
color="primary"
text="Nuevo proyecto"
(execute)="openCreate()"
/>
</div>
<pi-action-button
color="primary"
text="Nuevo proyecto"
(execute)="openCreate()"
/>
</header>
@if (facade.projects().length) {
<ts-my-work />
<div class="ts-section-header">
<h2>
Proyectos
<span class="ts-mono ts-muted">{{
pad(facade.projects().length)
}}</span>
</h2>
</div>
}
@if (facade.loading() && !facade.projects().length) {
<div class="ts-projects">
@for (i of [1, 2, 3]; track i) {
@@ -141,6 +162,27 @@ import { projectTone, timeAgo } from '../ui/copy';
</ng-template>
`,
styles: `
.ts-home-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
@media (max-width: 640px) {
width: 100%;
ts-go-to {
flex: 1;
}
}
}
.ts-section-header h2 {
display: flex;
align-items: baseline;
gap: 0.625rem;
.ts-mono {
font-size: 0.875rem;
font-weight: 400;
}
}
.ts-projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
@@ -216,6 +258,10 @@ import { projectTone, timeAgo } from '../ui/copy';
})
export class ProjectsPage implements OnInit {
protected readonly facade = inject(ProjectsFacade);
private readonly auth = inject(AuthFacade);
protected readonly firstName = computed(
() => this.auth.user()?.name.split(/\s+/)[0] ?? '',
);
private readonly drawer = inject(DrawerService);
private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm');