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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user