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,7 +8,9 @@ Modelo del tracker en el cliente.
## Contenido
- Modelos (`Project`, `IssueSummary`, `Issue`, `Release`…), enums e inputs.
- Workflow (`REASON_REQUIRED`, `BOARD_COLUMNS`).
- Configuración por proyecto (`ProjectSettings`, `StatusCategory`) y `Workflow`: nombres, colores, iconos, categorías,
transiciones permitidas y estados que piden motivo, con un nombre legible para ids que ya no están configurados.
- Estadísticas (`ProjectAnalytics`, `ReleaseBurnup`).
- Puertos: `ProjectsGateway`, `IssuesGateway`, `CommentsGateway`, `ReleasesGateway`, `ActivityGateway`, `UsersGateway`, `TrackerRealtimeGateway`.
## Comandos

View File

@@ -1,18 +1,13 @@
export enum IssueType {
BUG = 'BUG',
FEATURE = 'FEATURE',
UI_CHANGE = 'UI_CHANGE',
TASK = 'TASK',
/**
* What a status means, whatever each project calls it (statuses, ticket types
* and areas are per-project settings, see ProjectSettings).
*/
export enum StatusCategory {
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
QA = 'QA',
}
export enum IssueArea {
FRONTEND = 'FRONTEND',
BACKEND = 'BACKEND',
MOBILE = 'MOBILE',
QA = 'QA',
DESIGN = 'DESIGN',
DEVOPS = 'DEVOPS',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
export enum IssuePriority {
@@ -23,18 +18,6 @@ export enum IssuePriority {
CRITICAL = 'CRITICAL',
}
export enum IssueStatus {
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
READY_FOR_QA = 'READY_FOR_QA',
IN_QA = 'IN_QA',
QA_FAILED = 'QA_FAILED',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
export enum IssueLinkType {
BLOCKS = 'BLOCKS',
DEPENDS_ON = 'DEPENDS_ON',

View File

@@ -1,10 +1,8 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
StatusCategory,
} from './enums';
export interface CreateProjectInput {
@@ -15,10 +13,10 @@ export interface CreateProjectInput {
export interface CreateIssueInput {
projectId: string;
type: IssueType;
type: string;
title: string;
description?: string | null;
area?: IssueArea | null;
area?: string | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
@@ -34,7 +32,7 @@ export type UpdateIssueInput = Partial<
export interface ChangeStatusInput {
issueId: string;
status: IssueStatus;
status: string;
reason?: string | null;
}
@@ -57,9 +55,17 @@ export interface CreateReleaseInput {
targetDate?: string | null;
}
/** "My work": tickets across all my projects. */
export interface MyIssuesFilter {
/** true: assigned to me. false: any ticket of my projects. */
assignedToMe: boolean;
categories?: StatusCategory[];
limit: number;
}
export interface BoardFilter {
projectId: string;
assigneeId?: string;
releaseId?: string;
area?: IssueArea;
area?: string;
}

View File

@@ -1,12 +1,11 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
export interface UserRef {
id: string;
@@ -25,6 +24,7 @@ export interface Project {
description: string | null;
owner: UserRef;
members: ProjectMember[];
settings: ProjectSettings;
updatedAt: string;
version: number;
}
@@ -42,10 +42,12 @@ export interface IssueSummary {
id: string;
projectId: string;
key: string;
type: IssueType;
area: IssueArea | null;
/** Ids of the project's settings. */
type: string;
area: string | null;
title: string;
status: IssueStatus;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
assignee: UserRef | null;
release: { id: string; name: string } | null;
@@ -55,6 +57,12 @@ export interface IssueSummary {
version: number;
}
/** A page of tickets: the ones returned and how many match in total. */
export interface IssuePage {
items: IssueSummary[];
total: number;
}
export interface Issue extends IssueSummary {
description: string | null;
reporter: UserRef;
@@ -62,7 +70,7 @@ export interface Issue extends IssueSummary {
storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
allowedTransitions: IssueStatus[];
allowedTransitions: string[];
createdAt: string;
}
@@ -104,7 +112,7 @@ export interface CountBy<T = string> {
export interface ProjectStats {
total: number;
open: number;
byStatus: CountBy<IssueStatus>[];
byStatus: CountBy[];
byType: CountBy[];
byPriority: CountBy[];
byArea: CountBy[];
@@ -112,11 +120,46 @@ export interface ProjectStats {
}
export interface BoardColumn {
status: IssueStatus;
status: string;
issues: IssueSummary[];
total: number;
}
export interface DurationStats {
count: number;
averageDays: number | null;
/** 85% of tickets take at most this long. */
p85Days: number | null;
}
/** Statistics of a project over the last days. */
export interface ProjectAnalytics {
from: string;
to: string;
/** Per week (Monday, YYYY-MM-DD). */
createdVsResolved: { week: string; created: number; resolved: number }[];
/** Tickets in each status at the end of every day. */
cumulativeFlow: {
dates: string[];
series: { status: string; counts: number[] }[];
};
/** Start of work → done. */
cycleTime: DurationStats;
/** Creation → done. */
leadTime: DurationStats;
throughput: { week: string; user: UserRef | null; count: number }[];
qa: { passed: number; failed: number; failureRate: number | null };
}
/** Scope vs finished tickets of a release, day by day. */
export interface ReleaseBurnup {
releaseId: string;
targetDate: string | null;
dates: string[];
scope: number[];
done: number[];
}
export interface UserDirectoryEntry {
id: string;
name: string;

View File

@@ -1,41 +1,94 @@
import { IssuePriority, IssueStatus, IssueType } from './enums';
import { IssuePriority, StatusCategory } from './enums';
/** Statuses that require an explanation (the API rejects them without one). */
export const REASON_REQUIRED: readonly IssueStatus[] = [
IssueStatus.QA_FAILED,
IssueStatus.CANCELLED,
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
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. */
export const TYPE_ICONS = [
'bug',
'feature',
'palette',
'task',
'qa',
'flag',
'star-fill',
'rocket',
'document',
'light-bulb',
'key',
'support',
] as const;
export interface WorkflowStatus {
/** Stable id stored on tickets, e.g. IN_REVIEW. */
id: string;
name: string;
category: StatusCategory;
color: string;
/** Entering it needs a reason (the API rejects it without one). */
requiresReason: boolean;
/** No new tickets or transitions into it; existing tickets keep it. */
archived: boolean;
}
export interface StatusTransitions {
from: string;
to: string[];
}
export interface IssueTypeOption {
id: string;
name: string;
icon: string;
color: string;
archived: boolean;
}
export interface AreaOption {
id: string;
name: string;
archived: boolean;
}
/** Per-project configuration: statuses (board order), transitions, types and areas. */
export interface ProjectSettings {
statuses: WorkflowStatus[];
transitions: StatusTransitions[];
issueTypes: IssueTypeOption[];
areas: AreaOption[];
}
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
StatusCategory.DONE,
StatusCategory.CANCELLED,
];
export const BOARD_COLUMNS: readonly IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
/** Still needs someone's work. */
export const OPEN_CATEGORIES: readonly StatusCategory[] = [
StatusCategory.TODO,
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
export const STATUS_LABEL: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'To do',
[IssueStatus.IN_PROGRESS]: 'In progress',
[IssueStatus.IN_REVIEW]: 'In review',
[IssueStatus.READY_FOR_QA]: 'Ready for QA',
[IssueStatus.IN_QA]: 'In QA',
[IssueStatus.QA_FAILED]: 'QA failed',
[IssueStatus.DONE]: 'Done',
[IssueStatus.CANCELLED]: 'Cancelled',
};
export function isFinished(category: StatusCategory): boolean {
return FINISHED_CATEGORIES.includes(category);
}
export const TYPE_LABEL: Record<IssueType, string> = {
[IssueType.BUG]: 'Bug',
[IssueType.FEATURE]: 'Feature',
[IssueType.UI_CHANGE]: 'UI change',
[IssueType.TASK]: 'Task',
[IssueType.QA]: 'QA',
};
/** The default status where QA sends tickets back (highlighted in "my work"). */
export const QA_FAILED = 'QA_FAILED';
export const PRIORITY_ORDER: readonly IssuePriority[] = [
IssuePriority.CRITICAL,
@@ -45,6 +98,85 @@ export const PRIORITY_ORDER: readonly IssuePriority[] = [
IssuePriority.LOWEST,
];
export function isFinished(status: IssueStatus): boolean {
return status === IssueStatus.DONE || status === IssueStatus.CANCELLED;
const EMPTY: ProjectSettings = {
statuses: [],
transitions: [],
issueTypes: [],
areas: [],
};
/**
* Read-only lookups over a project's settings. Ids that are no longer
* configured (or unknown) still get a readable fallback.
*/
export class Workflow {
constructor(readonly settings: ProjectSettings = EMPTY) {}
status(id: string): WorkflowStatus {
return (
this.settings.statuses.find((s) => s.id === id) ?? {
id,
name: humanize(id),
category: StatusCategory.TODO,
color: 'slate',
requiresReason: false,
archived: true,
}
);
}
type(id: string): IssueTypeOption {
return (
this.settings.issueTypes.find((t) => t.id === id) ?? {
id,
name: humanize(id),
icon: 'task',
color: 'slate',
archived: true,
}
);
}
area(id: string): AreaOption {
return (
this.settings.areas.find((a) => a.id === id) ?? {
id,
name: humanize(id),
archived: true,
}
);
}
/** Active ticket types (to create or change tickets). */
activeTypes(): IssueTypeOption[] {
return this.settings.issueTypes.filter((t) => !t.archived);
}
activeAreas(): AreaOption[] {
return this.settings.areas.filter((a) => !a.archived);
}
requiresReason(id: string): boolean {
return this.status(id).requiresReason;
}
/** Active statuses a ticket in `from` can move to (the API has the last word). */
allowedFrom(from: string): string[] {
const targets =
this.settings.transitions.find((t) => t.from === from)?.to ?? [];
return targets.filter((to) =>
this.settings.statuses.some((s) => s.id === to && !s.archived),
);
}
/** Palette-icon types carry a Figma frame (UI changes by default). */
isDesignType(id: string): boolean {
return this.type(id).icon === 'palette';
}
}
/** IN_REVIEW → "In review" (for ids missing from the settings). */
function humanize(id: string): string {
const text = id.replace(/_/g, ' ').toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -5,12 +5,16 @@ import {
Comment,
CommandResult,
Issue,
IssuePage,
IssueSummary,
Project,
ProjectAnalytics,
ProjectStats,
Release,
ReleaseBurnup,
UserDirectoryEntry,
} from '../models/models';
import { ProjectSettings } from '../models/workflow';
import {
BoardFilter,
ChangeStatusInput,
@@ -19,6 +23,7 @@ import {
CreateReleaseInput,
LinkIssuesInput,
MemberInput,
MyIssuesFilter,
UpdateIssueInput,
} from '../models/inputs';
@@ -31,6 +36,16 @@ export abstract class ProjectsGateway {
abstract myProjects(): Observable<Project[]>;
abstract project(idOrKey: string): Observable<Project>;
abstract stats(projectId: string): Observable<ProjectStats>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,
days: number,
): Observable<ProjectAnalytics>;
/** Replaces statuses, transitions, types and areas (owners and admins). */
abstract updateSettings(
projectId: string,
settings: ProjectSettings,
): Observable<CommandResult>;
abstract create(input: CreateProjectInput): Observable<CommandResult>;
abstract addMember(input: MemberInput): Observable<CommandResult>;
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
@@ -44,6 +59,8 @@ export abstract class IssuesGateway {
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
abstract issue(idOrKey: string): Observable<Issue>;
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
/** Tickets across all my projects (most recently updated first). */
abstract mine(filter: MyIssuesFilter): Observable<IssuePage>;
abstract create(input: CreateIssueInput): Observable<CommandResult>;
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
@@ -68,12 +85,15 @@ export abstract class CommentsGateway {
export abstract class ReleasesGateway {
abstract list(projectId: string): Observable<Release[]>;
/** Planned releases of all my projects, soonest target date first. */
abstract upcoming(limit: number): Observable<Release[]>;
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
abstract publish(
releaseId: string,
force: boolean,
): Observable<CommandResult>;
abstract archive(releaseId: string): Observable<CommandResult>;
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
}
export abstract class ActivityGateway {