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

@@ -0,0 +1,166 @@
import {
BusinessRuleError,
ForbiddenError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { ProjectFactory } from '../factories/project.factory';
import { replay } from '../testing';
import { Project } from './project';
import { ProjectRole } from './project-role';
import {
defaultProjectSettings,
ProjectSettings,
StatusCategory,
} from './project-settings';
const newProject = () => {
const project = new ProjectFactory().create({
key: 'WEB',
name: 'Web',
ownerId: 'owner',
});
project.addMember('owner', 'dev', ProjectRole.MEMBER);
return project;
};
/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */
const withStaging = (): ProjectSettings => {
const settings = defaultProjectSettings();
settings.statuses.splice(4, 0, {
id: 'STAGING',
name: 'En staging',
category: StatusCategory.QA,
color: 'teal',
requiresReason: false,
archived: false,
});
settings.transitions['IN_REVIEW'] = ['STAGING'];
settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS'];
settings.issueTypes.push({
id: 'SPIKE',
name: 'Spike',
icon: 'light-bulb',
color: 'teal',
archived: false,
});
return settings;
};
describe('Project settings', () => {
it('starts with the default workflow, also when rebuilt from old events', () => {
const project = newProject();
expect(project.workflow.initialStatus()).toBe('BACKLOG');
expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true);
// Projects created before settings existed have no `settings` in their event.
const { history } = replay(project, Project);
delete (history[0].data as unknown as { project: { settings?: unknown } })
.project.settings;
const old = new Project(project.id);
old.loadFromHistory(history);
expect(old.settings).toEqual(defaultProjectSettings());
});
it('lets owners and admins customise it, as an event that replays', () => {
const project = newProject();
project.updateSettings('owner', withStaging());
const event = project.getUncommittedEvents().at(-1);
expect(event).toBeInstanceOf(ProjectSettingsChangedEvent);
expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']);
const { rebuilt } = replay(project, Project);
expect(rebuilt.workflow.status('STAGING')?.category).toBe(
StatusCategory.QA,
);
expect(() => project.updateSettings('dev', withStaging())).toThrow(
ForbiddenError,
);
});
it('archives values instead of deleting them', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE');
expect(() => project.updateSettings('owner', settings)).toThrow(
/archívalo/,
);
const archived = defaultProjectSettings();
archived.areas = archived.areas.map((a) =>
a.id === 'MOBILE' ? { ...a, archived: true } : a,
);
project.updateSettings('owner', archived);
expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow(
BusinessRuleError,
);
});
it('rejects settings that would break the board', () => {
const project = newProject();
const noStart = defaultProjectSettings();
noStart.statuses
.filter((s) => s.category === StatusCategory.TODO)
.forEach((s) => (s.archived = true));
expect(() => project.updateSettings('owner', noStart)).toThrow(
ValidationError,
);
const badTransition = defaultProjectSettings();
badTransition.transitions['TODO'] = ['NOPE'];
expect(() => project.updateSettings('owner', badTransition)).toThrow(
/no existe/,
);
const duplicate = defaultProjectSettings();
duplicate.issueTypes.push({ ...duplicate.issueTypes[0] });
expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/);
});
it('drives tickets: types, transitions and names in the history', () => {
const project = newProject();
project.updateSettings('owner', withStaging());
const workflow = project.workflow;
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Investigar pagos',
type: 'SPIKE',
reporterId: 'dev',
},
workflow,
);
for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING'])
issue.changeStatus('dev', to, null, workflow);
const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent;
expect(last.meta).toEqual({
fromName: 'En revisión',
toName: 'En staging',
fromCategory: StatusCategory.IN_PROGRESS,
toCategory: StatusCategory.QA,
});
// The default IN_REVIEW → READY_FOR_QA was replaced by STAGING.
expect(() =>
issue.changeStatus('dev', 'READY_FOR_QA', null, workflow),
).toThrow(BusinessRuleError);
expect(() =>
new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 2,
title: 'x',
type: 'EPIC',
reporterId: 'dev',
},
workflow,
),
).toThrow(/Unknown ticket type/);
});
});

View File

@@ -0,0 +1,395 @@
import {
BusinessRuleError,
ValidationError,
assertThat,
} from '@task-stream/api/shared/domain';
import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums';
/**
* What a status means, whatever it is called in each project: it keeps
* releases ("everything finished"), "waiting for QA" and statistics working
* with custom statuses.
*/
export enum StatusCategory {
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
QA = 'QA',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
/** Colour tokens the web app knows (light and dark). */
export const SETTING_COLORS = [
'slate',
'stone',
'violet',
'blue',
'sky',
'teal',
'green',
'amber',
'orange',
'red',
'pink',
] as const;
export type SettingColor = (typeof SETTING_COLORS)[number];
/** Icons a ticket type can use (registered in the web app). */
export const TYPE_ICONS = [
'bug',
'feature',
'palette',
'task',
'qa',
'flag',
'star-fill',
'rocket',
'document',
'light-bulb',
'key',
'support',
] as const;
export type TypeIcon = (typeof TYPE_ICONS)[number];
export interface WorkflowStatus {
/** Stable id stored on tickets, e.g. IN_REVIEW. */
id: string;
name: string;
category: StatusCategory;
color: SettingColor;
/** Entering it needs a reason (kept in the history). */
requiresReason: boolean;
/** Archived: no new tickets or transitions into it; existing ones keep it. */
archived: boolean;
}
export interface IssueTypeOption {
id: string;
name: string;
icon: TypeIcon;
color: SettingColor;
archived: boolean;
}
export interface AreaOption {
id: string;
name: string;
archived: boolean;
}
/** Per-project configuration of the tracker. */
export interface ProjectSettings {
/** In board order. New tickets start in the first active TODO status. */
statuses: WorkflowStatus[];
/** Allowed moves: status id → status ids it can go to. */
transitions: Record<string, string[]>;
issueTypes: IssueTypeOption[];
areas: AreaOption[];
}
const status = (
id: string,
name: string,
category: StatusCategory,
color: SettingColor,
requiresReason = false,
): WorkflowStatus => ({
id,
name,
category,
color,
requiresReason,
archived: false,
});
/** What every project starts with (and what projects created before settings had). */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
statuses: [
status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'),
status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'),
status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'),
status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'),
status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'),
status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'),
status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true),
status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'),
status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true),
],
// QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
transitions: {
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
[S.IN_QA]: [S.DONE, S.QA_FAILED],
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
[S.DONE]: [S.TODO, S.IN_PROGRESS],
[S.CANCELLED]: [S.BACKLOG],
},
issueTypes: [
{
id: IssueType.BUG,
name: 'Bug',
icon: 'bug',
color: 'red',
archived: false,
},
{
id: IssueType.FEATURE,
name: 'Feature',
icon: 'feature',
color: 'violet',
archived: false,
},
{
id: IssueType.UI_CHANGE,
name: 'Cambio de UI',
icon: 'palette',
color: 'pink',
archived: false,
},
{
id: IssueType.TASK,
name: 'Tarea',
icon: 'task',
color: 'slate',
archived: false,
},
{
id: IssueType.QA,
name: 'QA',
icon: 'qa',
color: 'amber',
archived: false,
},
],
areas: [
{ id: IssueArea.FRONTEND, name: 'Frontend', archived: false },
{ id: IssueArea.BACKEND, name: 'Backend', archived: false },
{ id: IssueArea.MOBILE, name: 'Mobile', archived: false },
{ id: IssueArea.QA, name: 'QA', archived: false },
{ id: IssueArea.DESIGN, name: 'Diseño', archived: false },
{ id: IssueArea.DEVOPS, name: 'DevOps', archived: false },
],
};
export function defaultProjectSettings(): ProjectSettings {
return structuredClone(DEFAULT_PROJECT_SETTINGS);
}
const ID = /^[A-Z][A-Z0-9_]{0,29}$/;
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
/** Read-only view of a project's workflow, used by tickets and queries. */
export class ProjectWorkflow {
constructor(readonly settings: ProjectSettings) {}
static default(): ProjectWorkflow {
return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS);
}
status(id: string): WorkflowStatus | undefined {
return this.settings.statuses.find((s) => s.id === id);
}
/** Name to show, also for ids no longer configured. */
statusName(id: string): string {
return (
this.status(id)?.name ??
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ??
id
);
}
typeName(id: string): string {
return (
this.settings.issueTypes.find((t) => t.id === id)?.name ??
DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ??
id
);
}
areaName(id: string): string {
return (
this.settings.areas.find((a) => a.id === id)?.name ??
DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ??
id
);
}
categoryOf(id: string): StatusCategory {
return (
this.status(id)?.category ??
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ??
StatusCategory.TODO
);
}
isFinished(id: string): boolean {
return FINISHED.includes(this.categoryOf(id));
}
/** Where new tickets start: the first active status of category TODO. */
initialStatus(): string {
const initial = this.settings.statuses.find(
(s) => !s.archived && s.category === StatusCategory.TODO,
);
if (!initial)
throw new BusinessRuleError('The project has no active starting status');
return initial.id;
}
/** Active statuses a ticket can move to from `from`. */
allowedFrom(from: string): string[] {
return (this.settings.transitions[from] ?? []).filter(
(to) => this.status(to) && !this.status(to)?.archived,
);
}
canTransition(from: string, to: string): boolean {
return this.allowedFrom(from).includes(to);
}
requiresReason(id: string): boolean {
return this.status(id)?.requiresReason ?? false;
}
assertActiveType(id: string): void {
const type = this.settings.issueTypes.find((t) => t.id === id);
if (!type) throw new ValidationError(`Unknown ticket type ${id}`);
if (type.archived)
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
}
assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id);
if (!area) throw new ValidationError(`Unknown area ${id}`);
if (area.archived)
throw new BusinessRuleError(`Area ${area.name} is archived`);
}
}
/**
* Checks and normalises new settings against the previous ones. Values can be
* added, renamed, reordered or archived, never deleted: tickets, history and
* statistics keep referring to them.
*/
export function validateProjectSettings(
next: ProjectSettings,
previous: ProjectSettings,
): ProjectSettings {
const statuses = next.statuses.map((s) => ({
id: normId(s.id),
name: normName(s.name),
category: s.category,
color: s.color,
requiresReason: !!s.requiresReason,
archived: !!s.archived,
}));
const issueTypes = next.issueTypes.map((t) => ({
id: normId(t.id),
name: normName(t.name),
icon: t.icon,
color: t.color,
archived: !!t.archived,
}));
const areas = next.areas.map((a) => ({
id: normId(a.id),
name: normName(a.name),
archived: !!a.archived,
}));
for (const [label, list, before] of [
['estado', statuses, previous.statuses],
['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas],
] as const) {
const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`);
const removed = before.find((item) => !ids.includes(item.id));
if (removed) {
throw new BusinessRuleError(
`No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`,
);
}
}
for (const s of statuses) {
assertThat(
Object.values(StatusCategory).includes(s.category),
`Categoría no válida en el estado ${s.name}`,
);
assertThat(
SETTING_COLORS.includes(s.color),
`Color no válido en el estado ${s.name}`,
);
}
for (const t of issueTypes) {
assertThat(
TYPE_ICONS.includes(t.icon),
`Icono no válido en el tipo ${t.name}`,
);
assertThat(
SETTING_COLORS.includes(t.color),
`Color no válido en el tipo ${t.name}`,
);
}
const active = statuses.filter((s) => !s.archived);
assertThat(
active.some((s) => s.category === StatusCategory.TODO),
'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets',
);
assertThat(
active.some((s) => s.category === StatusCategory.DONE),
'Hace falta al menos un estado activo «hecho»',
);
assertThat(
issueTypes.some((t) => !t.archived),
'Hace falta al menos un tipo de ticket activo',
);
const known = new Set(statuses.map((s) => s.id));
const transitions: Record<string, string[]> = {};
for (const [from, targets] of Object.entries(next.transitions ?? {})) {
const source = normId(from);
assertThat(
known.has(source),
`Transición desde un estado que no existe: ${from}`,
);
const to = [...new Set(targets.map(normId))];
for (const target of to) {
assertThat(
known.has(target),
`Transición hacia un estado que no existe: ${target}`,
);
assertThat(
target !== source,
`Un estado no puede pasar a sí mismo (${source})`,
);
}
if (to.length) transitions[source] = to;
}
return { statuses, transitions, issueTypes, areas };
}
function normId(id: string): string {
const value = (id ?? '').trim().toUpperCase();
assertThat(
ID.test(value),
`Id no válido «${id}»: mayúsculas, números y _, empezando por letra`,
);
return value;
}
function normName(name: string): string {
const value = (name ?? '').trim();
assertThat(
value.length > 0 && value.length <= 40,
'Los nombres llevan 1-40 caracteres',
);
return value;
}

View File

@@ -8,8 +8,15 @@ import { ProjectCreatedEvent } from '../events/project-created.event';
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event';
import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.event';
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
import { ProjectUpdatedEvent } from '../events/project-updated.event';
import { ProjectRole } from './project-role';
import {
defaultProjectSettings,
ProjectSettings,
ProjectWorkflow,
validateProjectSettings,
} from './project-settings';
export interface ProjectMember {
userId: string;
@@ -24,6 +31,8 @@ export interface ProjectProps {
description: string | null;
ownerId: string;
members: ProjectMember[];
/** Absent in projects created before settings existed: defaults apply. */
settings?: ProjectSettings;
createdAt: string;
updatedAt: string;
}
@@ -38,6 +47,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
public description: string | null = null;
public ownerId!: string;
public members: ProjectMember[] = [];
public settings: ProjectSettings = defaultProjectSettings();
public createdAt!: string;
public updatedAt!: string;
@@ -46,6 +56,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.id = id;
}
/** Statuses, transitions, types and areas of this project. */
get workflow(): ProjectWorkflow {
return new ProjectWorkflow(this.settings);
}
roleOf(userId: string): ProjectRole | null {
return this.members.find((m) => m.userId === userId)?.role ?? null;
}
@@ -98,6 +113,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.apply(new ProjectUpdatedEvent(actorId, this.id, patch));
}
/** Owners and admins customise statuses, transitions, types and areas. */
updateSettings(actorId: string, next: ProjectSettings): void {
this.assertCanManage(actorId);
const settings = validateProjectSettings(next, this.settings);
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
}
addMember(actorId: string, userId: string, role: ProjectRole): void {
this.assertCanManage(actorId);
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner');
@@ -154,6 +177,9 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.description = project.description;
this.ownerId = project.ownerId;
this.members = project.members.map((m) => ({ ...m }));
this.settings = structuredClone(
project.settings ?? defaultProjectSettings(),
);
this.createdAt = project.createdAt;
this.updatedAt = project.updatedAt;
}
@@ -163,6 +189,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.updatedAt = event.occurredAt;
}
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
this.settings = structuredClone(event.settings);
this.updatedAt = event.occurredAt;
}
onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void {
this.members = [
...this.members,