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:
@@ -7,9 +7,7 @@ import {
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueStatus,
|
||||
IssueStatusChangedEvent,
|
||||
IssueType,
|
||||
IssueUnlinkedEvent,
|
||||
IssueUpdatedEvent,
|
||||
ProjectCreatedEvent,
|
||||
@@ -17,7 +15,9 @@ import {
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectRole,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
@@ -31,28 +31,19 @@ export interface SummaryContext {
|
||||
issueKey: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
/** The project's workflow: names of statuses and types. */
|
||||
workflow: ProjectWorkflow;
|
||||
}
|
||||
|
||||
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
|
||||
|
||||
const STATUS: Record<IssueStatus, string> = {
|
||||
[IssueStatus.BACKLOG]: 'Backlog',
|
||||
[IssueStatus.TODO]: 'Por hacer',
|
||||
[IssueStatus.IN_PROGRESS]: 'En curso',
|
||||
[IssueStatus.IN_REVIEW]: 'En revisión',
|
||||
[IssueStatus.READY_FOR_QA]: 'Listo para QA',
|
||||
[IssueStatus.IN_QA]: 'En QA',
|
||||
[IssueStatus.QA_FAILED]: 'QA fallido',
|
||||
[IssueStatus.DONE]: 'Hecho',
|
||||
[IssueStatus.CANCELLED]: 'Cancelado',
|
||||
};
|
||||
|
||||
const TYPE: Record<IssueType, string> = {
|
||||
[IssueType.BUG]: 'bug',
|
||||
[IssueType.FEATURE]: 'feature',
|
||||
[IssueType.UI_CHANGE]: 'cambio de UI',
|
||||
[IssueType.TASK]: 'tarea',
|
||||
[IssueType.QA]: 'QA',
|
||||
/** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
|
||||
const DEFAULT_TYPE_WORDING: Record<string, string> = {
|
||||
BUG: 'bug',
|
||||
FEATURE: 'feature',
|
||||
UI_CHANGE: 'cambio de UI',
|
||||
TASK: 'tarea',
|
||||
QA: 'QA',
|
||||
};
|
||||
|
||||
const LINK: Record<IssueLinkType, string> = {
|
||||
@@ -98,6 +89,8 @@ export function describeEvent(
|
||||
return `${actor} creó el proyecto ${event.project.key}`;
|
||||
if (event instanceof ProjectUpdatedEvent)
|
||||
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectSettingsChangedEvent)
|
||||
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectMemberAddedEvent) {
|
||||
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
|
||||
}
|
||||
@@ -111,13 +104,19 @@ export function describeEvent(
|
||||
}
|
||||
|
||||
if (event instanceof IssueCreatedEvent) {
|
||||
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`;
|
||||
const type =
|
||||
DEFAULT_TYPE_WORDING[event.issue.type] ??
|
||||
ctx.workflow.typeName(event.issue.type);
|
||||
return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`;
|
||||
}
|
||||
if (event instanceof IssueUpdatedEvent) {
|
||||
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
|
||||
}
|
||||
if (event instanceof IssueStatusChangedEvent) {
|
||||
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`;
|
||||
// Names as they were then (old events: as configured now).
|
||||
const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from);
|
||||
const to = event.meta?.toName ?? ctx.workflow.statusName(event.to);
|
||||
return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`;
|
||||
}
|
||||
if (event instanceof IssueAssignedEvent) {
|
||||
return event.assigneeId
|
||||
|
||||
Reference in New Issue
Block a user